""" 饿了么闪购 — 主入口 V1 步骤驱动,按步执行 """ import sys import time import re import json import cv2 import numpy as np from pathlib import Path from typing import Optional sys.path.insert(0, str(Path(__file__).parent / "steps")) from ocr import OCR from executor import SafeExecutor from ai_helper1 import AIParser, parse_instructions, extract_value_after, detect_popup from ai_helper_vision import VisionParser from db import save_record, get_existing_license from snapshot import collect_snapshot # ── 配置 ──────────────────────────────────────────────── APP_PACKAGE = "me.ele" SCREENSHOT_DIR = Path(__file__).parent / "screenshots" # 生僻字标题修正开关: # True = 用云端文本修正标题中的生僻字漏字(如"理王"→"理洫王") # False = 标题保持OCR原样(默认,更安全——避免形近字误修正,如"温胃舒"被改成"养胃舒"导致错误采集) ENABLE_RARE_CHAR_CORRECT = True # 标题过滤开关: # True = 按品牌/药品名/规格过滤,只采目标商品(正常模式) # False = 不过滤,直接采集OCR识别的全部结果入库(测试OCR准确率用) ENABLE_TITLE_FILTER = False # 视觉识别开关: # True = 截图直接送视觉大模型识别(方案A,生僻字更准,坐标精度依赖模型) # False = 本地OCR + 文本AI解析(原有方案) USE_VISION_PARSER = True # 验证码频率控制: # 一天内 ≥CAPTCHA_DAILY_LIMIT 次 → 立即停止采集并回告(风控可能封号) # 每累计 CAPTCHA_REST_EVERY 次 → 休息 CAPTCHA_REST_MINUTES 分钟(第2/4/6次休息,第8次直接停止) CAPTCHA_DAILY_LIMIT = 8 CAPTCHA_REST_EVERY = 2 CAPTCHA_REST_MINUTES = 30 CAPTCHA_LOG_DIR = Path(__file__).parent / "logs" # 验证码日志目录(每设备独立一个文件,独立计数) CAPTCHA_ABORTED = False # 全局标志:验证码频繁触发停止采集(调度上报用) CAPTCHA_ABORT_REASON = "" # 停止原因(调度回告用) ACCOUNT_ABORTED = False # 全局标志:账号被踢/封号,停止采集(调度上报用) CURRENT_PAGE = 0 # 当前页码(逐页回告/终止回告用,调度侧存续采集进度) # 验证码判定截图存档目录(每次判定出现验证码时截图保存,人工确认是否误判) CAPTCHA_CHECK_DIR = Path(__file__).parent / "logs" / "captcha_check" # 休息期间进度回告用的全局上下文(step3 任务开始时设置,_captcha_rest 休息时每10分钟回告) _SCHEDULER = None _TASK_ID = None _CRAWLED_COUNT = 0 OCR = OCR() # 说明书打叉坐标缓存(仅本任务内有效):任务中第一个商品模板匹配找到打叉后, # 本任务后续商品直接复用该坐标(说明书页布局固定,同设备位置不变); # 新任务开始时清空,重新匹配。 _CLOSE_BTN_CACHE = None def _find_device(device_id: str = "") -> str: import subprocess r = subprocess.run(["adb", "devices"], capture_output=True, text=True, timeout=5) devices = [] for line in r.stdout.strip().split("\n")[1:]: if line.strip() and "device" in line and "offline" not in line: s = line.split("\t")[0].strip() if s: devices.append(s) if not devices: raise RuntimeError("未找到设备") # 如果指定了设备ID,精确匹配 if device_id: for d in devices: if d == device_id: return d raise RuntimeError(f"未找到指定设备: {device_id},可用设备: {devices}") # 只有一台直接返回 if len(devices) == 1: return devices[0] # 多台设备:列出并让用户选择 print(f"\n发现 {len(devices)} 台设备:") for i, d in enumerate(devices): print(f" [{i}] {d}") while True: try: choice = input(f"请选择设备 [0-{len(devices)-1}],回车默认第一台: ").strip() if choice == "": return devices[0] idx = int(choice) if 0 <= idx < len(devices): return devices[idx] except ValueError: pass print(f"输入无效,请输入 0-{len(devices)-1}") def _find_text_in_area(shot_path: str, target: str, max_y: int) -> Optional[dict]: results = OCR.recognize(shot_path, detail="all") for r in results: if target in r["text"]: y = r["bbox"][0][1] if y < max_y: cx = r["bbox"][0][0] + (r["bbox"][2][0] - r["bbox"][0][0]) // 2 cy = y + (r["bbox"][2][1] - y) // 2 return {"x": cx, "y": cy, "text": r["text"], "conf": r["confidence"]} return None def _shot_path(ex: SafeExecutor, name: str) -> str: """截图路径:按 设备ID/步骤 分类组织目录(screenshots/设备/step1/xxx.png)""" cat = "misc" for prefix, c in (("step1", "step1"), ("step2", "step2"), ("step3", "step3"), ("_s4_", "step4"), ("sort", "step2"), ("inst_", "instructions"), ("lic_", "license"), ("snap_", "snapshot"), ("ad_", "popup")): if name.startswith(prefix): cat = c break d = SCREENSHOT_DIR / ex.device_id / cat d.mkdir(parents=True, exist_ok=True) return str(d / name) def _screenshot(ex: SafeExecutor, name: str) -> str: import os # 按 设备ID/步骤 隔离截图(目录已含设备ID,文件名不再加设备前缀) path = _shot_path(ex, name) if os.path.exists(path): # 保留历史截图副本:固定文件名被 test/调试脚本引用,不能被后续运行覆盖丢失 import shutil stem, ext = os.path.splitext(name) backup = str(Path(path).parent / f"{stem}_{int(time.time() * 1000)}{ext}") try: shutil.copy2(path, backup) except Exception: pass # 自动清理:每个固定文件只保留最近3份备份,超出删除最旧的 try: olds = sorted( Path(path).parent.glob(f"{stem}_[0-9]*{ext}"), key=lambda p: p.stat().st_mtime, reverse=True, ) for p in olds[3:]: p.unlink(missing_ok=True) except Exception: pass # 方案2:截图后验证完整性(adb 流式传输可能中断,导致 PNG 损坏) for _try in range(3): ex.driver.screenshot(path) if cv2.imread(path) is not None: break time.sleep(0.5) return path def _where_am_i(ex: SafeExecutor) -> str: """判断当前位置:list=搜索结果列表页 home=首页 other=其他(全屏OCR,区分列表页与首页防止退过头)""" import os as _os tmp = _shot_path(ex, "check_pos.png") ex.driver.screenshot(tmp) if cv2.imread(tmp) is None: ex.driver.screenshot(tmp) texts = [r["text"] for r in OCR.recognize(tmp, detail="all")] if any("筛选" in t for t in texts): return "list" if any("看病买药" in t for t in texts) and any("我的" in t for t in texts): return "home" return "other" def _is_search_page(ex: SafeExecutor) -> bool: """判断当前是否在搜索页面:只检测屏幕顶部20%区域内是否有「筛选」""" import tempfile, os w, h = ex.driver.window_size() tmp = _shot_path(ex, "check_search.png") ex.driver.screenshot(tmp) if cv2.imread(tmp) is None: ex.driver.screenshot(tmp) texts = OCR.recognize(tmp, rect=[0, 0, w, int(h * 0.2)], detail="text") return "筛选" in texts # ── 步骤 1:打开 App ──────────────────────────────────── def step1_open_app(ex: SafeExecutor) -> bool: print("=" * 40) print(" 步骤 1:打开饿了么闪购") print("=" * 40) w, h = ex.driver.window_size() print(f"[step1] 屏幕尺寸: {w}x{h}") print(f"[step1] 关闭 {APP_PACKAGE}...") ex.driver.app_stop(APP_PACKAGE) time.sleep(2) print(f"[step1] 启动 {APP_PACKAGE}...") ex.driver.app_start(APP_PACKAGE) time.sleep(5) # 等待首页加载:底部导航「我的」出现(元素没加载完就多等重试,不一次定生死) for attempt in range(3): shot = _screenshot(ex, "step1_home.png") texts = OCR.recognize(shot, rect=[0, int(h * 0.88), w, h], detail="text") print(f"[step1] 底部识别(第{attempt+1}次): {texts}") if any("我的" in t for t in texts): print("[step1] OK - 成功进入 App") _close_ad_popup(ex) # 每天首启的广告弹窗 return True time.sleep(3) print("[step1] FAIL - 多次重试未检测到「我的」") # 识别不到"我的"可能因为账号被踢/封号——检测登录页xpath确认(存在才是封号) _check_account_kicked(ex) return False # ── 步骤 2:搜索商品 ──────────────────────────────────── def _click_low_price_sort(ex: SafeExecutor) -> bool: """ 搜索结果排序:点「综合」→ 选「低价优先」(失败不阻塞,找不到就跳过)。 找不到「低价优先」时再点一次「综合」重试,仍找不到则关闭排序弹窗。 """ try: # 1. 找「综合」并点击(排序入口,结果页顶部) shot = _shot_path(ex, "sort1.png") ex.driver.screenshot(shot) btn = None for r in OCR.recognize(shot, detail="all"): if "综合" in r["text"]: box = r["bbox"] btn = {"x": (box[0][0] + box[2][0]) // 2, "y": (box[0][1] + box[2][1]) // 2} break if not btn: print(" ⚠ 未找到「综合」排序入口,跳过排序") return False print(f" 排序: 点击「综合」({btn['x']}, {btn['y']})") ex.tap(btn["x"], btn["y"]) time.sleep(2) # 2. 找「低价优先」 shot2 = _shot_path(ex, "sort2.png") ex.driver.screenshot(shot2) low = None for r in OCR.recognize(shot2, detail="all"): if "低价优先" in r["text"]: box = r["bbox"] low = {"x": (box[0][0] + box[2][0]) // 2, "y": (box[0][1] + box[2][1]) // 2} break if low: print(f" 排序: 点击「低价优先」({low['x']}, {low['y']})") ex.tap(low["x"], low["y"]) time.sleep(3) return True # 3. 没找到:再点一次「综合」关闭排序弹窗(点击综合弹出面板,再点一次收起) print(" ⚠ 未找到「低价优先」,再点一次「综合」关闭排序弹窗") ex.tap(btn["x"], btn["y"]) time.sleep(1.5) return False except Exception as e: print(f" ⚠ 排序异常: {e}") return False def step2_search(ex: SafeExecutor, keyword: str) -> bool: print("\n" + "=" * 40) print(f" 步骤 2:搜索「{keyword}」") print("=" * 40) w, h = ex.driver.window_size() top_th = int(h * 0.3) # ── 阶段0+1:点击「看病买药」→ 找「搜索」(失败重试3次,3次失败重启App重试,最多2轮)── btn = None for round_idx in range(2): if round_idx > 0: # 第2轮:重启 App 重新进入(弹窗/页面异常时清状态) print("[step2] 重启App重新进入...") if not step1_open_app(ex): print("[step2] FAIL - 重启App失败") return False # 阶段0:点击「看病买药」 shot0 = _screenshot(ex, "step2_phase0.png") btn_med = _find_text_in_area(shot0, "看病买药", h) if not btn_med: print(f"[step2] 未找到「看病买药」(第{round_idx+1}轮)") continue print(f"[step2] 找到「看病买药」: ({btn_med['x']}, {btn_med['y']})") ex.tap(btn_med["x"], btn_med["y"]) time.sleep(5) _close_ad_popup(ex) # 点击买药后可能出现的广告弹窗 # 阶段1:找「搜索」(重试3次,每次关弹窗+等待) for attempt in range(3): shot = _screenshot(ex, "step2_phase1.png") btn = _find_text_in_area(shot, "搜索", top_th) if btn: break print(f"[step2] 未找到「搜索」(第{attempt+1}次),关闭弹窗后重试") _close_ad_popup(ex) time.sleep(2) if btn: break # 找到搜索,继续 print("[step2] 3次未找到「搜索」,准备重启App重试") if not btn: print("[step2] FAIL - 多次重试+重启后仍未找到「搜索」") return False print(f"[step2] 找到「搜索」: ({btn['x']}, {btn['y']})") cx = btn["x"] - 300 # 搜索左边约120px cy = btn["y"] print(f"[step2] 点击搜索栏: ({cx}, {cy})") ex.tap(cx, cy) time.sleep(5) # 搜索页确认:「搜索」位置变化才认为进入(加载慢/弹窗遮挡/点击未生效就重试) moved = False btn2 = None for attempt in range(3): shot2 = _screenshot(ex, "step2_phase2.png") btn2 = _find_text_in_area(shot2, "搜索", top_th) if btn2 and (abs(btn2["x"] - btn["x"]) > 50 or abs(btn2["y"] - btn["y"]) > 50): moved = True print(f"[step2] 搜索页搜索: ({btn2['x']}, {btn2['y']})") break # 位置没变:先关广告弹窗 + AI验证码检测,再重新点击搜索栏(可能点击没生效或被验证码拦截) if _ai_check_captcha(ex): print(f"[step2] AI检测到验证码(第{attempt+1}次),处理完成") if _close_ad_popup(ex): print(f"[step2] 已关闭广告弹窗(第{attempt+1}次)") print(f"[step2] 搜索位置未变化(第{attempt+1}次),重新点击搜索栏") ex.tap(cx, cy) time.sleep(4) if not btn2: print("[step2] FAIL - 进入搜索页后找不到「搜索」") return False if not moved: print("[step2] FAIL - 搜索位置未改变") return False cx2 = btn2["x"] - 180 # 搜索页输入框在搜索左边约180px cy2 = btn2["y"] ex.tap(cx2, cy2) time.sleep(2) print(f"[step2] 聚焦输入框,等待2s") print(f"[step2] 输入关键词: {keyword}") ex.driver.set_input_ime(True) time.sleep(0.3) ex.driver.send_keys(keyword) time.sleep(1) ex.tap(btn2["x"], btn2["y"]) time.sleep(3) # 搜索后检测列表页:先处理验证码(可能挡住列表页),再检测列表页特征(加载慢就重试) for attempt in range(3): shot3 = _screenshot(ex, "step2_result.png") raw3 = OCR.recognize(shot3, detail="all") all_texts = [r["text"] for r in raw3] # 1. 验证码优先:验证码弹窗会挡住列表页特征,先处理再重新检测 # 关键词没命中时用AI再判断一次(九宫格/点击式验证码文字不在关键词里) if any(("拖动滑块" in t) or ("请按住滑块" in t) or ("安全验证" in t) for t in all_texts): print(f"[step2] 检测到列表页验证码(第{attempt+1}次),尝试处理...") if _handle_captcha(ex, all_texts): print("[step2] 验证码已解决") else: print("[step2] 验证码处理失败") return False # 验证码通过后可能出现「出错了/检修中」页,点重新加载 if _click_reload(ex): print("[step2] 已点击重新加载") time.sleep(2) continue if _ai_check_captcha(ex): print(f"[step2] AI检测到验证码(第{attempt+1}次),处理完成") continue # 1.2 广告弹窗(进入列表页时也可能弹出,挡住列表页特征) if _close_ad_popup(ex): print(f"[step2] 已关闭列表页广告弹窗(第{attempt+1}次)") continue # 1.5 出错/检修页(无验证码时也可能出现):点「重新加载」后重新检测 if any("重新加载" in t for t in all_texts): print(f"[step2] 检测到「出错了/检修中」页面,点击重新加载") _click_reload(ex) continue # 2. 列表页特征检测 has_filter = "筛选" in all_texts has_express = "快递" in all_texts kw_found = any(keyword in t for t in all_texts) print(f"[step2] 有筛选: {has_filter}, 有快递: {has_express}, 关键词存在: {kw_found}") if has_filter or has_express: # 如果有「快递」则点击它 if has_express: for r in raw3: if "快递" in r["text"]: bx = r["bbox"] cx = (bx[0][0] + bx[2][0]) // 2 cy = (bx[0][1] + bx[2][1]) // 2 print(f"[step2] 点击「快递」: ({cx}, {cy})") ex.tap(cx, cy) time.sleep(3) break # 点击「快递」后设置排序:综合 → 低价优先 # _click_low_price_sort(ex) print("[step2] OK - 搜索成功") return True print(f"[step2] 列表页特征未出现(第{attempt+1}次),等3秒重试") time.sleep(3) print("[step2] FAIL - 搜索未成功") return False def normalize_match_text(value): """归一化:统一全角/半角、去除空白和零宽字符(美团同款,避免'看起来一样但匹配失败')""" import unicodedata text = "" if value is None else str(value) text = unicodedata.normalize("NFKC", text) text = re.sub(r"[\s ​-‍]+", "", text) return text def _edit_distance(a: str, b: str) -> int: """编辑距离(Levenshtein),用于OCR错字容错""" if len(a) < len(b): a, b = b, a if not b: return len(a) prev = list(range(len(b) + 1)) for i, ca in enumerate(a, 1): cur = [i] for j, cb in enumerate(b, 1): cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb))) prev = cur return prev[-1] def _match_fuzzy(target: str, text: str) -> bool: """ 容错匹配:目标词是否在文本中(处理OCR漏字/错字1个)。 用于判断「疑似生僻字」——本地识别差1个字时触发云端验证。 """ if not target: return True if target in text: return True if len(target) >= 3: for i in range(len(target)): # 漏字:目标删任意1字后匹配 if target[:i] + target[i + 1:] in text: return True for L in (len(target), len(target) + 1, len(target) - 1): # 错字:编辑距离<=1 if L < 2: continue for i in range(len(text) - L + 1): if _edit_distance(target, text[i:i + L]) <= 1: return True return False def _correct_title_with_cloud(title: str, n_brand: str, n_key: str, cloud_texts: list) -> str: """ 用云端文本修正标题中的生僻字错误(精确子串替换,不做整标题替换): 标题里 fuzzy 匹配到目标词的子串 → 替换为云端文本确认的准确形式。 只处理"漏字"场景(云端形式更长,如"理王"→"理洫王"); 同长度的形近替换(如"温胃舒"→"养胃舒")【不修正】—— 云端文本里的目标词可能来自其他商品,误用来改标题会造成错误采集。 """ c_joined = "".join(cloud_texts) nt = normalize_match_text(title) for target in (n_brand, n_key): if not target: continue if target in nt: continue # 已精确匹配,无需修正 if target not in c_joined: continue # 云端也没有准确形式 → 无法修正,跳过 # 在标题里找漏字子串并替换为准确形式。 # 只处理"目标词比子串长且编辑距离≤1"(漏字:理王→理洫王)—— # 同长度形近替换(温胃舒→养胃舒)不触发:云端文本里的目标词可能来自其他商品, # 误用来改标题会造成错误采集。 for i in range(len(nt)): for L in (len(target) - 1, len(target), len(target) + 1): if L < 2 or i + L > len(nt): continue sub = nt[i:i + L] if len(target) > len(sub) and _edit_distance(target, sub) <= 1: nt = nt[:i] + target + nt[i + L:] break else: continue break return nt def _is_cjk(ch: str) -> bool: """是否中文字符""" return '一' <= ch <= '鿿' def _is_missing_char(target: str, title_text: str) -> bool: """ 判断目标词是否在标题里以"漏1字"形式出现(生僻字特征): 目标词删掉1个字后的子串,在标题里是【独立词】(前后不是汉字)。 例:理洫王删"洫"="理王",标题"[理王]"里独立 → 漏字 ✓ 例:养胃舒删"舒"="养胃",标题"滋阴养胃"里嵌在词中(前有"滋")→ 非独立 → 不算漏字 ✗ """ for k in range(len(target)): sub = target[:k] + target[k + 1:] if len(sub) < 2: continue idx = title_text.find(sub) while idx != -1: before_ok = idx == 0 or not _is_cjk(title_text[idx - 1]) after_ok = idx + len(sub) >= len(title_text) or not _is_cjk(title_text[idx + len(sub)]) if before_ok and after_ok: return True idx = title_text.find(sub, idx + 1) return False def _ensure_cloud(cloud_cache: dict, shot_path: str) -> None: """触发一次云端OCR(每批只调一次),保存 (文本, y) 带坐标的块列表""" if cloud_cache["done"]: return cloud_cache["done"] = True try: raw_c = OCR.recognize(shot_path, detail="all", engine="cloud") if not raw_c: # 云端返回空(配额用尽/限流时百度返回错误码但不抛异常)→ 视为云端不可用 print("[step3] 云端返回空结果(可能配额不足/限流),按容错处理") cloud_cache["blocks"] = None cloud_cache["texts"] = None return cloud_cache["blocks"] = [ (normalize_match_text(r["text"]), (r["bbox"][0][1] + r["bbox"][2][1]) // 2) for r in raw_c ] cloud_cache["texts"] = [t for t, _ in cloud_cache["blocks"]] print(f"[step3] 云端二次确认({len(cloud_cache['blocks'])}块)") except Exception as e: print(f"[step3] 云端识别失败({e})") cloud_cache["blocks"] = None cloud_cache["texts"] = None def _cloud_region_text(cloud_cache: dict, card_y: int, max_dy: int = 300) -> Optional[str]: """ 取卡片 y±max_dy 内的所有云端块合并文本(标题块可能被拆成多块,只取最近一块会漏)。 阈值300px:排除相邻卡片的目标词(温胃舒 y=867 vs 养胃舒 y=1305 差438 > 300 ✓) """ blocks = cloud_cache.get("blocks") if not blocks: return None parts = [t for t, cy in blocks if abs(cy - card_y) <= max_dy] return "".join(parts) if parts else None def _cloud_crop_confirm(shot_path: str, card_y: int) -> Optional[str]: """ 用本地OCR的标题块坐标裁剪标题区域(标题y-20 ~ y+80,右列),放大2倍后云端识别。 裁剪区只含当前商品的标题 → 云端不需要返回坐标,标准版(无配额问题)即可用,且字放大识别更准。 """ try: img = cv2.imread(shot_path) if img is None: return None h, w = img.shape[:2] y1, y2 = max(0, card_y - 20), min(h, card_y + 80) x1 = int(w * 0.15) crop = img[y1:y2, x1:w] if crop.size == 0: return None big = cv2.resize(crop, None, fx=2, fy=2, interpolation=cv2.INTER_LANCZOS4) raw = OCR.recognize(big, detail="all", engine="cloud") texts = [normalize_match_text(r["text"]) for r in raw if any('一' <= c <= '鿿' for c in r["text"])] return "".join(texts) if texts else None except Exception as e: print(f" [调试] 裁剪云端确认异常: {e}") return None def _match_verify(title: str, n_brand: str, n_key: str, shot_path: str, cloud_cache: dict, card_y: int = None) -> tuple: """ 品牌/药品名匹配(只判断品牌+药品名核心词,规格/功效文字不参与)。 返回 (判定, 不匹配原因):判定 "ok"/"fuzzy"/"fail",原因如 "品牌"/"药品名"/"品牌、药品名" - 本地精确匹配 → "ok"(免费) - 完全不像 → "fail"(免费,不花云端) - 差1字/漏字 → 用本地坐标裁剪该商品标题区域,云端识别确认: 裁剪区含目标词(本地认错字,如理王→理洫王/甲疏咪唑)→ "ok" 裁剪区不含目标词(确实不是该商品,如温胃舒vs养胃舒)→ "fail" - 裁剪确认不可用 → 漏字场景容错放行 "fuzzy",换字场景 "fail" """ nt = normalize_match_text(title) brand_match = (not n_brand) or (n_brand in nt) key_match = (not n_key) or (n_key in nt) if brand_match and key_match: return "ok", "" def _unmatched_reason(): parts = [] if n_brand and not brand_match: parts.append("品牌") if n_key and not key_match: parts.append("药品名") return "、".join(parts) or "品牌/药品名" # 便宜判断:完全不像(非差1字也非漏字)→ 直接过滤,不花云端 near_brand = (not n_brand) or _match_fuzzy(n_brand, nt) or _is_missing_char(n_brand, nt) near_key = (not n_key) or _match_fuzzy(n_key, nt) or _is_missing_char(n_key, nt) if not (near_brand and near_key): return "fail", _unmatched_reason() # 差1字/漏字 → 用本地坐标裁剪该商品标题区域,云端确认(标准版即可,无需坐标) if card_y is not None: crop_text = _cloud_crop_confirm(shot_path, card_y) if crop_text is not None: if (not n_brand or n_brand in crop_text) and (not n_key or n_key in crop_text): # 把裁剪确认文本交给标题修正逻辑(如"理王"→"理洫王") cloud_cache["texts"] = [crop_text] return "ok", "" print(f" [调试] 卡片y={card_y} 裁剪云端=[{crop_text[:40]}] 不含目标词") return "fail", _unmatched_reason() print(f" [调试] 卡片y={card_y} 裁剪云端识别失败") # 裁剪确认不可用 → 原逻辑:漏字(独立词)→ 整页云端兜底;换字 → 过滤 missing = [] if n_brand and not brand_match and _is_missing_char(n_brand, nt): missing.append("品牌") if n_key and not key_match and _is_missing_char(n_key, nt): missing.append("药品名") if not missing: return "fail", _unmatched_reason() # 换字 → 过滤(安全默认) # 漏字 → 整页云端兜底(cloud_cache 保证每批只调一次) _ensure_cloud(cloud_cache, shot_path) if cloud_cache["texts"] is None: return "fuzzy", "" c_joined = "".join(cloud_cache["texts"]) if (not n_brand or n_brand in c_joined) and (not n_key or n_key in c_joined): return "ok", "" return "fail", _unmatched_reason() def _screen_state(texts: list) -> str: """ 根据 OCR 文本判断屏幕状态(统一的状态识别,各步骤共用): detail=商品详情页 shop=店铺页 list=搜索结果列表页 unknown=其他 """ joined = "".join(texts) if any(("加入购物车" in t) or ("立即购买" in t) or ("选规格" in t) or ("加入购物袋" in t) for t in texts): return "detail" if ("刚刚搜过" in joined) and ("评价" in joined): return "shop" if "筛选" in joined: return "list" return "unknown" def _is_list_page(ex: SafeExecutor) -> bool: """当前是否在搜索结果列表页(全屏检测「筛选」,店铺页/详情页不含此词)""" import os as _os tmp = _shot_path(ex, "check_list.png") ex.driver.screenshot(tmp) if cv2.imread(tmp) is None: ex.driver.screenshot(tmp) texts = [r["text"] for r in OCR.recognize(tmp, detail="all")] return any("筛选" in t for t in texts) def _find_ad_close(shot_path: str) -> Optional[dict]: """ 广告弹窗打叉按钮:二值化模板匹配,形状匹配不受颜色/背景干扰。 双模板双二值化(固定阈值100 + OTSU自适应,各自同阈值组合),不同弹窗打叉深浅不同,取最佳。 命中返回 {"x","y"}。 """ base_dir = Path(__file__).parent / "files" screen = cv2.imread(shot_path, cv2.IMREAD_GRAYSCALE) if screen is None: return None h_s, w_s = screen.shape[:2] base_w = 1220.0 # 模板裁自1220宽屏,其他分辨率按比例缩放 scale_ratio = w_s / base_w scales = [round(scale_ratio * s, 2) for s in [0.8, 0.9, 1.0, 1.1, 1.2]] best_val, best_loc, best_sw, best_sh = 0, None, 0, 0 # (模板文件, 截图二值化方式):同阈值组合(固定×固定、OTSU×OTSU) pairs = ( ("ad_close_bin.png", lambda g: cv2.threshold(g, 100, 255, cv2.THRESH_BINARY_INV)[1]), ("ad_close_otsu.png", lambda g: cv2.threshold(g, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]), ) for tpl_name, binarize in pairs: template = cv2.imread(str(base_dir / tpl_name), cv2.IMREAD_GRAYSCALE) if template is None: continue screen_bin = binarize(screen) for scale in scales: scaled = cv2.resize(template, None, fx=scale, fy=scale) sw, sh = scaled.shape[1], scaled.shape[0] if sh > h_s or sw > w_s: continue res = cv2.matchTemplate(screen_bin, scaled, cv2.TM_CCOEFF_NORMED) _, mv, _, ml = cv2.minMaxLoc(res) if mv > best_val: best_val, best_loc, best_sw, best_sh = mv, ml, sw, sh # 阈值0.4:二值化形状匹配值高(实测0.65+),误匹配低 if best_val >= 0.4 and best_loc is not None: sx = best_loc[0] + best_sw // 2 sy = best_loc[1] + best_sh // 2 # 位置约束:打叉在弹窗正下方(下半屏 y>0.4h),上半屏匹配视为误报 if sy < int(h_s * 0.4): return None return {"x": sx, "y": sy} return None def _click_reload(ex: SafeExecutor) -> bool: """检测「出错了/正在检修中」页面并点击「重新加载」(验证码通过后可能出现)""" try: shot = _shot_path(ex, "reload_check.png") ex.driver.screenshot(shot) if cv2.imread(shot) is None: ex.driver.screenshot(shot) btn = None for r in OCR.recognize(shot, detail="all"): if "重新加载" in r["text"]: box = r["bbox"] btn = {"x": (box[0][0] + box[2][0]) // 2, "y": (box[0][1] + box[2][1]) // 2} break if not btn: return False print(f" 点击「重新加载」: ({btn['x']}, {btn['y']})") ex.tap(btn["x"], btn["y"]) time.sleep(3) return True except Exception as e: print(f" ⚠ 重新加载处理异常: {e}") return False def _has_popup_mask(shot_path: str) -> bool: """ 弹窗遮罩检测:弹窗出现时周围被半透明遮罩变暗(暗区比例大幅上升)。 实测:有弹窗暗区0.18-0.33,无弹窗<0.1,阈值0.12安全区分。 """ img = cv2.imread(shot_path, cv2.IMREAD_GRAYSCALE) if img is None: return False dark_ratio = float((img < 80).mean()) return dark_ratio > 0.12 def _close_ad_popup(ex: SafeExecutor) -> bool: """ 检测并关闭广告弹窗: ① 遮罩检测(弹窗周围变暗,本地计算零成本,最快最准) ② AI 找关闭按钮(云端OCR → AI判断) ③ 模板匹配打叉兜底 """ try: shot = _shot_path(ex, "ad_check.png") ex.driver.screenshot(shot) if cv2.imread(shot) is None: ex.driver.screenshot(shot) # 1. 遮罩检测:无弹窗直接返回(不浪费AI调用) if not _has_popup_mask(shot): return False print(" 检测到弹窗(周围遮罩变暗)") # 2. AI 找关闭按钮(云端OCR → AI判断坐标) raw = OCR.recognize(shot, detail="all", engine="cloud") info = detect_popup(raw) if info["has_popup"] and info.get("close_xy") and len(info["close_xy"]) == 2: print(f" 弹窗检测(AI): {info['reason']}") print(f" 关闭广告弹窗(AI坐标): ({info['close_xy'][0]}, {info['close_xy'][1]})") ex.tap(int(info["close_xy"][0]), int(info["close_xy"][1])) time.sleep(1.5) return True # 3. 模板匹配打叉兜底(弹窗正下方居中的 ×) btn = _find_ad_close(shot) if btn: print(f" 关闭广告弹窗(模板): ({btn['x']}, {btn['y']})") ex.tap(btn["x"], btn["y"]) time.sleep(1.5) return True print(" ⚠ 检测到弹窗但未找到关闭按钮(AI无坐标+模板未命中)") return True except Exception as e: print(f" ⚠ 广告弹窗处理异常: {e}") return False def _adb_swipe_up(ex: SafeExecutor, distance: int): """ADB 手指从下往上滑,内容下滑""" import subprocess w, h = ex.driver.window_size() swipe_x = w // 2 seg = 3 seg_px = distance // seg for i in range(seg): s = int(h * 0.8) - i * 80 e = s - seg_px if e < 50: e = 50 subprocess.run( ["adb", "-s", ex.device_id, "shell", "input", "swipe", str(swipe_x), str(s), str(swipe_x), str(e), "400"], capture_output=True, timeout=10 ) time.sleep(0.35) time.sleep(1.4) def _adb_swipe_left(ex: SafeExecutor, y_ratio: float = 0.3): """从右往左滑:切换商品图片轮播(图片在详情页上半部分)""" import subprocess w, h = ex.driver.window_size() y = int(h * y_ratio) subprocess.run( ["adb", "-s", ex.device_id, "shell", "input", "swipe", str(int(w * 0.85)), str(y), str(int(w * 0.15)), str(y), "300"], capture_output=True, timeout=10 ) time.sleep(1.2) def _adb_swipe_up_short(ex: SafeExecutor): """上滑半屏:说明书页内容可能需滑动才全部可见""" import subprocess w, h = ex.driver.window_size() subprocess.run( ["adb", "-s", ex.device_id, "shell", "input", "swipe", str(w // 2), str(int(h * 0.7)), str(w // 2), str(int(h * 0.3)), "300"], capture_output=True, timeout=10 ) time.sleep(1) def _find_close_btn(shot_path: str) -> Optional[dict]: """ 说明书页右上角找打叉关闭按钮(多尺度模板匹配,适配多分辨率)。 模板 files/close.png,命中返回 {"x","y"},失败返回 None。 """ import os as _os tpl_path = str(Path(__file__).parent / "files" / "close.png") screen = cv2.imread(shot_path) template = cv2.imread(tpl_path) if screen is None or template is None: return None h_s, w_s = screen.shape[:2] # 右上角区域(打叉永远在右上角) roi_x1, roi_y1 = w_s * 2 // 3, 0 roi = screen[roi_y1:h_s // 4, roi_x1:w_s] # 多尺度模板匹配(分辨率适配:以720p为基准按屏宽比例缩放) base_w = 720.0 scale_ratio = w_s / base_w scales = [round(scale_ratio * s, 2) for s in [0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5]] best_val, best_loc, best_sw, best_sh = 0, None, 0, 0 for scale in scales: scaled = cv2.resize(template, None, fx=scale, fy=scale) sw, sh = scaled.shape[1], scaled.shape[0] if sh > roi.shape[0] or sw > roi.shape[1]: continue res = cv2.matchTemplate(roi, scaled, cv2.TM_CCOEFF_NORMED) _, mv, _, ml = cv2.minMaxLoc(res) if mv > best_val: best_val, best_loc, best_sw, best_sh = mv, ml, sw, sh t_edge = cv2.Canny(scaled, 30, 100) r_edge = cv2.Canny(roi, 30, 100) if t_edge.shape[0] <= r_edge.shape[0] and t_edge.shape[1] <= r_edge.shape[1]: res2 = cv2.matchTemplate(r_edge, t_edge, cv2.TM_CCOEFF_NORMED) _, mv2, _, ml2 = cv2.minMaxLoc(res2) if mv2 > best_val: best_val, best_loc, best_sw, best_sh = mv2, ml2, sw, sh if best_val >= 0.26 and best_loc is not None: sx = roi_x1 + best_loc[0] + best_sw // 2 sy = best_loc[1] + best_sh // 2 return {"x": sx, "y": sy} return None def _collect_instructions(ex: SafeExecutor) -> dict: """ 商品详情页采集说明书(采完链接后调用): back关分享弹窗 → 左滑商品图片 → 点「查看详细说明」 → 说明书页截图OCR → 提取批准文号/有效期(最多上滑3次兜底) 返回 {"approval_no": "", "validity": ""},任何失败留空不抛异常 """ w, h = ex.driver.window_size() pfx = f"{ex.device_id}_inst" # in_detail: 是否确认停留在商品详情页——主流程据此决定后续资质采集是否安全执行 result = {"approval_no": "", "validity": "", "in_detail": False} # 1. 确认屏幕状态:分享弹窗→back关闭;在详情页→开始;在列表页→跳过(不再back,避免乱退) for _try in range(3): shot = _shot_path(ex, "inst_check.png") ex.driver.screenshot(shot) if cv2.imread(shot) is None: ex.driver.screenshot(shot) texts = [r["text"] for r in OCR.recognize(shot, detail="all")] if any("分享到" in t for t in texts): print(" 关闭分享弹窗") ex.driver.press("back") time.sleep(1.2) continue state = _screen_state(texts) if state == "detail": result["in_detail"] = True break if state == "list": print(" ⚠ 说明书采集跳过:已在列表页(step4未成功进店),不再back") return result if _try < 2: print(f" 屏幕状态[{state}],back一次重新确认") ex.driver.press("back") time.sleep(1.2) else: print(" ⚠ 说明书采集跳过:多次确认仍不在商品详情页") return result # 2. 左滑切换商品图片,找「查看详细说明」按钮 _adb_swipe_left(ex, 0.3) shot2 = _shot_path(ex, "inst_btn.png") ex.driver.screenshot(shot2) btn = _find_text_in_area(shot2, "查看详细说明", h) if not btn: print(" ⚠ 未找到「查看详细说明」,跳过说明书采集") return result print(f" 说明书按钮: ({btn['x']}, {btn['y']})") ex.tap(btn["x"], btn["y"]) time.sleep(2.5) _ai_check_captcha(ex) # 说明书页可能出现验证码(无关键词检查环节) # 3. 说明书页截图 + OCR 提取(模仿美团:批准文号和有效期都找到才停,最多滑3次) for attempt in range(4): shot3 = _shot_path(ex, "inst_page.png") ex.driver.screenshot(shot3) inst = parse_instructions(OCR.recognize(shot3, detail="all", engine="cloud")) if inst["approval_no"] and inst["validity"]: print(f" 说明书: 批准文号={inst['approval_no']} 有效期={inst['validity']}") result = inst break if attempt < 3: missing = [k for k in ("approval_no", "validity") if not inst[k]] print(f" 说明书字段不全(第{attempt+1}次,缺{missing}),上滑重试") _adb_swipe_up_short(ex) else: print(" ⚠ 说明书页4次均未解析到批准文号/有效期") result = inst # 4. 说明书采集完成:点右上角打叉关闭说明书页(不能back——back会直接回列表页) # 本任务内缓存:第一个商品找到后,后续商品直接复用坐标 global _CLOSE_BTN_CACHE close_btn = _CLOSE_BTN_CACHE if close_btn is None: close_shot = _shot_path(ex, "inst_close.png") ex.driver.screenshot(close_shot) close_btn = _find_close_btn(close_shot) if close_btn: _CLOSE_BTN_CACHE = close_btn print(f" 关闭说明书页: ({close_btn['x']}, {close_btn['y']})(本任务已缓存,后续商品复用)") else: print(" ⚠ 未找到打叉按钮(后续步骤会按屏幕状态自行处理)") else: print(f" 关闭说明书页: ({close_btn['x']}, {close_btn['y']})(复用本任务缓存坐标)") if close_btn: ex.tap(close_btn["x"], close_btn["y"]) time.sleep(1.2) return result def _collect_snapshot(ex: SafeExecutor, title: str) -> str: """ 网页快照(采集说明书之后调用): 先识别屏幕状态——已在详情页直接拍;说明书页等未知页则back一次回详情页再拍; 在列表页/店铺页等明确非详情页位置直接跳过(不再back,避免把列表页退到首页) """ try: # 1. 先截图识别状态,决定是否需要 back shot = _shot_path(ex, "snap_check.png") for _try in range(2): ex.driver.screenshot(shot) if cv2.imread(shot) is None: ex.driver.screenshot(shot) texts = [r["text"] for r in OCR.recognize(shot, detail="all")] state = _screen_state(texts) if state == "detail": break if state in ("list", "shop"): print(f" ⚠ 快照跳过:屏幕状态[{state}],不在详情页也不再back") return "" if _try == 0: # 说明书页/其他未知页:back 一次回详情页再确认 print(f" 快照:屏幕状态[{state}],back回详情页") ex.driver.press("back") time.sleep(1.2) else: print(f" ⚠ 快照跳过:back后屏幕状态[{state}],不在详情页") return "" else: print(" ⚠ 快照跳过:无法确认在详情页") return "" # 2. 滚动截图 + 上传OSS(美团同款逻辑在 snapshot 模块) url, snap_reason = collect_snapshot(ex.driver, title, ex.device_id) print(f" 📷 快照: {url if url else f'失败({snap_reason})'}") if url: # 快照滚动改变了页面位置:back 回店铺页,再开始资质采集 ex.driver.press("back") time.sleep(1.2) return url except Exception as e: print(f" ⚠ 快照采集异常: {e}") return "" def _collect_license(ex: SafeExecutor, shop_name: str) -> dict: """ 采集商家资质(采完说明书后调用,屏幕在说明书页): back回店铺内 → OCR上半区找店铺名点击 → 下半区找「查看营业资质」点击 → 云端OCR找「资质编号」取值 → 点编号下方约3cm打开营业执照 → 百度营业执照专用接口OCR 返回 {"license_no": "", "license": {}},任何失败留空不抛异常 """ w, h = ex.driver.window_size() pfx = f"{ex.device_id}_lic" result = {"license_no": "", "license": {}} # 0. 已采集过的店铺:直接从数据库获取资质,不重复采集(美团/PDD同款) try: exist = get_existing_license(shop_name) if exist.get("license_no") or exist.get("company"): print(f" 资质已存在,从数据库获取: 编号={exist['license_no'][:24]} 公司={exist['company'][:20]}") result["license_no"] = exist["license_no"] lic = {} if exist.get("company"): lic["单位名称"] = exist["company"] if exist.get("address"): lic["地址"] = exist["address"] result["license"] = lic return result except Exception as e: print(f" ⚠ 查询已有资质失败: {e}") # 1+2. 回到店铺页并采资质(最多2轮):每轮先找店铺名,找不到/点后无资质入口就 back 回退一层再看 # 注意不能无条件back:屏幕已在店铺页时再退会到列表页 def _find_license_btn(): # 先搜下半区,找不到整张图分析(「查看营业资质」位置不固定,有时在上半区) for rect in ([0, int(h * 0.45), w, h], None): for r in OCR.recognize(shot2, rect=rect, detail="all"): if "查看营业资质" in r["text"]: box = r["bbox"] return {"x": (box[0][0] + box[2][0]) // 2, "y": (box[0][1] + box[2][1]) // 2} return None lic_btn = None for attempt in range(2): if _is_list_page(ex): print(" ⚠ 资质采集跳过:已在列表页(不点列表卡片)") return result shot = _shot_path(ex, "lic_shopname.png") ex.driver.screenshot(shot) shop_btn = _find_text_in_area(shot, shop_name, h // 2) if shop_btn: print(f" 店铺名: ({shop_btn['x']}, {shop_btn['y']}) (第{attempt+1}轮)") ex.tap(shop_btn["x"], shop_btn["y"]) time.sleep(2.5) # 下半区找「查看营业资质」,找不到先上滑一次再看 shot2 = _shot_path(ex, "inst_btn.png") ex.driver.screenshot(shot2) lic_btn = _find_license_btn() if not lic_btn: print(f" 未找到「查看营业资质」(第{attempt+1}轮),上滑再看") _adb_swipe_up_short(ex) shot2 = _shot_path(ex, "inst_btn.png") ex.driver.screenshot(shot2) lic_btn = _find_license_btn() if lic_btn: break if attempt == 0: # 屏幕可能在详情页/说明书页:back 回退一层再看 print(" back一次回退后再试") ex.driver.press("back") time.sleep(1.5) else: print(" ⚠ 资质采集跳过:未找到店铺名或「查看营业资质」") return result print(f" 查看营业资质: ({lic_btn['x']}, {lic_btn['y']})") ex.tap(lic_btn["x"], lic_btn["y"]) time.sleep(2.5) _ai_check_captcha(ex) # 资质页可能出现验证码(无关键词检查环节) # 4. 资质页:云端OCR找「资质编号」取值 shot3 = _shot_path(ex, "lic_no.png") ex.driver.screenshot(shot3) raw3 = OCR.recognize(shot3, detail="all", engine="cloud") license_no = extract_value_after(raw3, "资质编号") if license_no: # OCR可能把长编号读散(如 "91450800MA5 KEHAHXT"),编号不该有空格,清洗掉 license_no = license_no.replace(" ", "") if not license_no: print(" ⚠ 资质采集跳过:未找到资质编号") return result print(f" 资质编号: {license_no}") result["license_no"] = license_no # 5. 点资质编号下方约3cm(≈0.2屏高)打开营业执照大图 # 注意:云端OCR(标准版)无坐标,必须用本地OCR找资质编号的真实位置 no_x, no_y = None, None for r in OCR.recognize(shot3, detail="all"): # 本地引擎,有真实bbox t = r["text"].strip().rstrip(":: \t") if t.startswith("资质编号"): box = r["bbox"] no_x = (box[0][0] + box[2][0]) // 2 no_y = (box[0][1] + box[2][1]) // 2 break if no_y is None: print(" ⚠ 资质采集跳过:本地OCR未找到资质编号位置") return result print(f" 资质编号位置: ({no_x}, {no_y}),点击下方打开执照") ex.tap(no_x, min(no_y + int(h * 0.2), h - 50)) time.sleep(2.5) # 6. 营业执照截图 + 百度营业执照专用接口(美团同款;执照在屏幕中下部,先裁剪再识别,逐级兜底) shot4 = _shot_path(ex, "lic_license.png") ex.driver.screenshot(shot4) lic = {} for t_ratio, b_ratio in [(0.35, 0.8), (0.4, 1.0), (0.0, 1.0)]: lic = OCR.recognize_license(shot4, rect=[0, int(h * t_ratio), w, int(h * b_ratio)]) if lic: break if lic: print(f" 营业执照: {lic}") result["license"] = lic else: print(" ⚠ 营业执照OCR为空") return result def _detect_right_col_split(raw: list, w: int) -> Optional[int]: """ 定位列表页"图片|文字"分界x:每张卡片都有价格,¥全在右列且x一致。 取¥块左边缘x的中位数 − 15 作为分界(图片区在左、标题/价格在右)。 实测3台设备:分界499时图片文字最右仅474,过滤干净。返回None=检测不到(用整图)。 """ price_x = sorted(r["box"][0] for r in raw if "¥" in r["text"] or "¥" in r["text"]) if len(price_x) < 3: return None return max(price_x[len(price_x) // 2] - 15, int(w * 0.3)) def _get_named_shops(ex: SafeExecutor, shot_name: str, keyword: str = "") -> tuple: """截图 + OCR + AI → 返回 (有店铺名的列表, 本地OCR原始结果带坐标)""" shot = _screenshot(ex, shot_name) raw = OCR.recognize(shot, detail="all") w, h = ex.driver.window_size() # 只保留右列(商品描述列):用¥定位分界,过滤左侧图片文字(包装字/英文/乱码) split_x = _detect_right_col_split(raw, w) if split_x: filtered = [r for r in raw if (r["box"][0] + r["box"][2]) // 2 >= split_x] if filtered: print(f"[step3] 右列识别: 分界x={split_x},过滤掉{len(raw) - len(filtered)}块图片文字") raw = filtered # 在截图副本上画红线保存(出错时核对分界是否偏左/偏右) try: img = cv2.imread(shot) if img is not None: cv2.line(img, (split_x, 0), (split_x, img.shape[0]), (0, 0, 255), 3) cv2.putText(img, f"split={split_x}", (split_x + 5, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2) cv2.imwrite(shot.replace(".png", "_split.png"), img) except Exception: pass if USE_VISION_PARSER: # 方案A:截图送视觉模型(生僻字更准),裁掉左列商品图片只留右列文字区 # crop_x=split_x 来自OCR分界检测,AI只看文字区,y取标题和店铺名的中点 shops = VisionParser().parse_shops(shot, screen_size=(w, h), keyword=keyword, crop_x=split_x or 0) # AI估y不准会点到上一个店铺,用OCR精确坐标修正 _correct_vision_coords_with_ocr(shops, raw) # 视觉模型失败时 fallback 到 OCR+文本AI if not shops: print("[step3] 视觉模型未识别到店铺,fallback 到 OCR+文本AI") shops = AIParser().parse_shops(raw, screen_size=(w, h), keyword=keyword) else: # 原方案:本地OCR文本 → 文本AI解析 shops = AIParser().parse_shops(raw, screen_size=(w, h), keyword=keyword) # 只保留有效店铺名+价格:店铺名必须含中文或字母(排除纯数字/标点/空格) import re as _re valid = [] for s in shops: name = (s[0] or "").strip() price = (s[2] or "").strip() if name and _re.search(r'[一-鿿＀-￯a-zA-Z]', name) and price: valid.append(s) return valid, raw def _find_title_y(raw: list, title: str) -> Optional[int]: """在本地OCR结果里找与标题开头重合最多的块的y坐标(标题行位置,用于裁剪)""" nt = normalize_match_text(title) best_len, best_cy = 0, None for r in raw: t = normalize_match_text(r["text"]) if not t: continue n = 0 for a, b in zip(t, nt): if a == b: n += 1 else: break if n > best_len: best_len = n best_cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2 return best_cy if best_len >= 2 else None def _correct_vision_coords_with_ocr(shops: list, raw: list) -> None: """用OCR精确坐标修正VisionParser返回的y坐标。 AI估y不准会点到上一个店铺,用OCR里店铺名和标题的精确y重新算中点。 直接修改 shops 列表中的坐标,不返回新列表。""" for s in shops: shop_name = str(s[0] or "").strip() title = str(s[1] or "").strip() # 在OCR里找店铺名 → 精确y(子串匹配,取最长匹配的块) shop_y = None if shop_name and len(shop_name) >= 2: ns = normalize_match_text(shop_name) best_len, best_y = 0, None for r in raw: t = normalize_match_text(r["text"]) if not t or len(t) < 2: continue if ns in t or t in ns: if len(t) > best_len: best_len = len(t) best_y = (r["bbox"][0][1] + r["bbox"][2][1]) // 2 shop_y = best_y # 在OCR里找标题 → 精确y(复用已有函数) title_y = _find_title_y(raw, title) if title else None # 用OCR坐标修正点击坐标 if shop_y is not None and title_y is not None: s[3][1] = (title_y + shop_y) // 2 # 点击y = 标题和店铺名中点 s[4] = shop_y print(f" [coord] OCR修正: {shop_name[:10]} y={s[3][1]} (标题y={title_y} 店铺y={shop_y})") elif shop_y is not None: s[3][1] = max(shop_y - 150, 0) # 只有店铺名y,往上150点击 s[4] = shop_y print(f" [coord] OCR修正(仅店铺名): {shop_name[:10]} y={s[3][1]}") def _shop_key(shop: list) -> str: """用店铺名+价格去重(去括号内分店名、去尾部点号)""" import re name = shop[0] price = shop[2] if len(shop) > 2 else "" name = name.replace("(", "(").replace(")", ")") name = re.sub(r'(.*', '', name) name = re.sub(r'[..…]+$', '', name) return f"{name.strip()}|{price.strip()}" def _visit_shop(ex: SafeExecutor, shop: list, visited: set, keyword: str = "", task: dict = None) -> dict: """ 点击进入店铺 → step4 → 说明书 → 快照 → 资质 → 返回完整数据 dict task: 调度任务 dict(task_id/enterprise_id/collect_round等),手动模式传 None """ """点击进入店铺 → step4 → 返回完整数据 dict""" key = _shop_key(shop) if key in visited: return None visited.add(key) shop_name = shop[0] product_title = shop[1] price = shop[2] sales = str(shop[5]) if len(shop) > 5 and shop[5] else "" click_x, click_y = shop[3] print(f" → 进入 [{shop_name}] 商品: {product_title[:30]} 价格: {price} 月售: {sales}") ex.tap(click_x, click_y) try: qr_url = step4_parse_qr(ex, product_title, shop_name, shop_xy=[click_x, click_y]) except Exception as e: print(f" ⚠ step4异常: {e},跳过此店铺") qr_url = "" if qr_url == "__TERMINATE__": print(f" ⚠ 遇到终止信号,停止遍历") return {"__terminate__": True} if qr_url == "__RESTART__": print(f" ⚠ 页面异常,触发重启恢复") return {"__restart__": True} if qr_url: print(f" ✅ QR: {qr_url[:80]}") print(f" 📦 采集完成: {shop_name} | {product_title[:30]} | {price} | 月售{sales} | {qr_url[:60]}") else: print(f" ⚠ 未获取到二维码链接") print(f" 📦 采集完成(无链接): {shop_name} | {product_title[:30]} | {price} | 月售{sales}") # 内存熔断:本地OCR已OOM → 说明书/快照/资质全部跳过(状态检测不可靠,避免乱back乱点) if getattr(OCR, "oom", False): print(" ⚠ 内存不足熔断:跳过说明书/快照/资质,直接返回列表页(请关闭部分程序释放内存)") inst = {"approval_no": "", "validity": "", "in_detail": False} snapshot_url = "" lic = {"license_no": "", "license": {}} else: # 采完链接后采集说明书(批准文号/有效期),失败不阻塞 try: inst = _collect_instructions(ex) except Exception as e: print(f" ⚠ 说明书采集异常: {e}(疑似内存不足,请关闭部分程序)") inst = {"approval_no": "", "validity": "", "in_detail": False} print(f" 📄 说明书: 批准文号={inst['approval_no']} 有效期={inst['validity']}") # 采完说明书后采集网页快照(美团同款顺序:说明书→快照→资质),失败不阻塞 snapshot_url = _collect_snapshot(ex, product_title) # 采完快照后采集商家资质(_collect_license 会先 back 回店铺页,再按屏幕状态自校验:列表页/无店铺名都跳过) try: lic = _collect_license(ex, shop_name) except Exception as e: print(f" ⚠ 资质采集异常: {e}") lic = {"license_no": "", "license": {}} print(f" 📋 资质编号: {lic['license_no']} 单位名称: {lic['license'].get('单位名称', '')} 信用代码: {lic['license'].get('社会信用代码', '')}") # 返回搜索页:先检测再退(已是列表页则一步不退;退到首页立即停,防止退过头退出app) for _ in range(5): try: pos = _where_am_i(ex) except Exception as e: print(f" ⚠ 位置检测异常({e}),停止返回") break if pos == "list": break if pos == "home": print(" ⚠ 已退到首页(可能退过头),停止返回") break ex.driver.press("back") time.sleep(1.4) task = task or {} return { "shop": shop_name, "title": product_title, "price": price, "sales": sales, "approval_no": inst["approval_no"], "validity": inst["validity"], "license_no": lic["license_no"], "license": lic["license"], "snapshot_url": snapshot_url, "search_name": keyword, "link": qr_url or "", "task_id": task.get("task_id"), "enterprise_id": task.get("enterprise_id"), "collect_round": task.get("collect_round"), "collect_equipment_account_id": task.get("collect_equipment_account_id"), "collect_region_id": task.get("collect_region_id"), "collect_config_info": task.get("collect_config_info", ""), } def _captcha_log_path(device_id: str) -> Path: """每台设备独立的验证码日志文件(独立计数,互不影响)""" return CAPTCHA_LOG_DIR / f"captcha_log_{device_id}.txt" def _log_captcha(ex: SafeExecutor) -> bool: """ 记录验证码出现时间到日志,检查一天≥8次停止。 返回 True=已触发停止(上层不再休息),False=正常(处理完成后按频率休息)。 """ global CAPTCHA_ABORTED, CAPTCHA_ABORT_REASON import os as _os ts = time.strftime("%Y-%m-%d %H:%M:%S") line = f"{ts} 验证码出现" try: _os.makedirs(_os.path.dirname(_captcha_log_path(ex.device_id)), exist_ok=True) with open(_captcha_log_path(ex.device_id), "a", encoding="utf-8") as f: f.write(line + "\n") except Exception as e: print(f" [验证码记录] 写日志失败: {e}") print(f" [验证码记录] {line}") # 一天内 ≥8次 → 立即停止采集回告(风控可能封号) today_count = _captcha_count_today(ex.device_id) print(f" [验证码记录] 设备{ex.device_id}今天已出现{today_count}次") if today_count >= CAPTCHA_DAILY_LIMIT: CAPTCHA_ABORTED = True CAPTCHA_ABORT_REASON = f"一天内验证码达{today_count}次,进入风控可能封号" print(f" ⚠ {CAPTCHA_ABORT_REASON},停止采集") return True return False def _captcha_count_today(device_id: str) -> int: """统计该设备日志中今天(按日期)的验证码次数""" try: today = time.strftime("%Y-%m-%d") count = 0 with open(_captcha_log_path(device_id), encoding="utf-8") as f: for line in f: if line.startswith(today): count += 1 return count except Exception: return 0 def _save_captcha_check(ex: SafeExecutor) -> None: """每次判定出现验证码时截图保存到 logs/captcha_check/{日期}/,人工确认是否真验证码""" import os as _os try: day = time.strftime("%Y-%m-%d") d = CAPTCHA_CHECK_DIR / day d.mkdir(parents=True, exist_ok=True) ts = time.strftime("%H%M%S") path = str(d / f"{ts}_{ex.device_id}_captcha.png") ex.driver.screenshot(path) print(f" [验证码截图] 已保存: {path}") except Exception as e: print(f" [验证码截图] 保存失败: {e}") def _handle_captcha(ex: SafeExecutor, ocr_texts: list) -> bool: """处理验证码, 重试5次, 失败等人工, 返回True=已解决; 只有自动解决成功才计数(失败走人工的不算),成功后按频率休息""" _save_captcha_check(ex) # 判定出现验证码时截图存档(人工确认是否误判) import sys as _sys _sys.path.insert(0, r"D:\drug\sg\yzm") solved = False for attempt in range(1, 6): print(f" [验证码] 第{attempt}次尝试...") nine_kw = any("提交" in t or "没有新图片" in t for t in ocr_texts) if nine_kw: from nine_grid import solve as solve_nine ok = solve_nine(ex.driver) else: from tmp_captcha_test6 import solve_slider ok = solve_slider(ex.driver) if ok: print(f" ✅ 验证码已解决") solved = True break print(f" ❌ 第{attempt}次失败") time.sleep(1) if solved: # 只有解决成功才计数 + 一天≥8次停止检查 + 每2次休息30分钟 aborted = _log_captcha(ex) if not aborted: _captcha_rest(ex) else: print(f" ⚠ 5次自动处理失败, 请人工处理...(不计数)") input(" 处理完成后按回车继续...") return True def _captcha_rest(ex: SafeExecutor) -> None: """验证码处理完成后:每累计2次休息30分钟;休息期间每10分钟回告一次进度(防后台判假死)""" today_count = _captcha_count_today(ex.device_id) if today_count % CAPTCHA_REST_EVERY == 0 and today_count < CAPTCHA_DAILY_LIMIT: print(f" ⏸ 已出现{today_count}次验证码,休息{CAPTCHA_REST_MINUTES}分钟...") rest_left = CAPTCHA_REST_MINUTES * 60 while rest_left > 0: chunk = min(rest_left, 600) # 每10分钟一段 time.sleep(chunk) rest_left -= chunk # 休息期间每10分钟回告当前进度(后台超过10分钟无回告会判假死) if rest_left > 0 and _SCHEDULER is not None: try: _SCHEDULER.post_report({ "task_id": _TASK_ID, "platform": _SCHEDULER.platform, "username": _SCHEDULER.username, "is_finished": 0, "need_reassign": 0, "current_page": CURRENT_PAGE, "crawled_count": _CRAWLED_COUNT, }) print(f" [休息中回告] 当前页{CURRENT_PAGE},已采{_CRAWLED_COUNT}条") except Exception as e: print(f" [休息中回告] 失败: {e}") print(f" ▶ 休息结束,继续采集") # 验证码强特征词(店铺页/列表页文字多但无这些词——AI判risk时用OCR文字二次确认防误判) CAPTCHA_KW = ("拖动滑块", "请按住滑块", "请按照说明", "点我反馈", "进行验证", "滑块验证", "拼图", "安全验证", "图形验证", "点击完成验证", "没有新图片", "操作频繁") def _has_captcha_kw(texts: list) -> bool: """OCR文字是否含验证码强特征词""" joined = "".join(texts) return any(k in joined for k in CAPTCHA_KW) def _ai_check_captcha(ex: SafeExecutor) -> bool: """ AI检测当前屏幕是否出现验证码(用于没有关键词检查的环节): 截图 → 本地OCR → 强特征词预筛 → AI判断页面类型(risk=验证码)→ 有则自动处理。 返回 True=检测到验证码(已处理或处理中),False=无验证码。 """ try: shot = _shot_path(ex, "captcha_ai.png") ex.driver.screenshot(shot) if cv2.imread(shot) is None: ex.driver.screenshot(shot) raw = OCR.recognize(shot, detail="all") # 本地OCR即可(验证码文字是大字,不用百度) # 预筛:OCR文本里连强特征词都没有 → 不是验证码,不调AI(省调用+防误判) if not _has_captcha_kw([r["text"] for r in raw]): return False page = AIParser().check_page(raw) if page.get("type") == "risk": print(f" ⚠ AI检测到验证码页面: {page.get('detail', '')}") _handle_captcha(ex, [r["text"] for r in raw]) time.sleep(2) return True return False except Exception as e: print(f" ⚠ AI验证码检测异常: {e}") return False def _check_account_kicked(ex: SafeExecutor) -> bool: """ 检测账号是否被踢/封号:登录页元素(me.ele:id/login_onkey_login_ll)出现即判定。 检测到 → 置 ACCOUNT_ABORTED 标志(停止采集 + 调度回告)。 """ global ACCOUNT_ABORTED try: if ex.driver.xpath('//*[@resource-id="me.ele:id/login_onkey_login_ll"]').exists: print(" ⚠ 检测到账号被踢/封号(登录页),停止采集") ACCOUNT_ABORTED = True return True except Exception as e: print(f" ⚠ 封号检测异常: {e}") return False def step4_parse_qr(ex: SafeExecutor, product_title: str, shop_name: str = "", shop_xy: Optional[list] = None) -> str: """ 1. 等待加载 → OCR → AI找商品标题坐标 2. 点击商品标题 → 进入商品详情 3. 找右上角"分享" → 点击 → 二维码弹窗 4. 截图 → pyzbar 解析二维码 返回 URL 或空字符串 """ # 安全的文件名前缀(用hash避免中文路径cv2兼容问题) # 多设备隔离:加入设备ID,防止并发时两台设备写同一个文件 import hashlib _hash = hashlib.md5(shop_name.encode()).hexdigest()[:8] if shop_name else "unknown" _pfx = lambda name: _shot_path(ex, f"_s4_{_hash}_{name}") time.sleep(6) # ── 检测页面类型:验证码/风控/正常 ── # unknown恢复流程:等1秒二次截图确认 → 3次确认unknown → back一层+重新点击进入(刷新)→ 再检测 → 仍unknown才重启 unknown_round = 0 backed_once = False for page_retry in range(9): shot_check = _pfx("page_check.png") ex.driver.screenshot(shot_check) check_raw = OCR.recognize(shot_check, detail="all") # 方法A: 模板匹配检测验证码 import os as _os captcha_tpl = str(Path(__file__).parent / "files" / "captcha1.png") if _os.path.exists(captcha_tpl): si = cv2.imread(shot_check) ti = cv2.imread(captcha_tpl) if si is not None and ti is not None: gs = cv2.cvtColor(si, cv2.COLOR_BGR2GRAY) gt = cv2.cvtColor(ti, cv2.COLOR_BGR2GRAY) h_s, w_s = gs.shape crop_y1, crop_y2 = int(h_s * 0.25), int(h_s * 0.75) crop_x1, crop_x2 = 0, 400 gs_crop = gs[crop_y1:crop_y2, crop_x1:crop_x2] scores = [] for fn, ss, tt in [ ("gray", gs_crop, gt), ("edge", cv2.Canny(gs_crop,30,100), cv2.Canny(gt,30,100)), ("hist", cv2.equalizeHist(gs_crop), cv2.equalizeHist(gt)), ("blur", cv2.GaussianBlur(gs_crop,(3,3),0), cv2.GaussianBlur(gt,(3,3),0)), ("otsu", cv2.threshold(gs_crop,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)[1], cv2.threshold(gt,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)[1]), ]: if ss.ndim == 2 and tt.ndim == 2 and ss.shape[0] >= tt.shape[0] and ss.shape[1] >= tt.shape[1]: r = cv2.matchTemplate(ss, tt, cv2.TM_CCOEFF_NORMED) _, mv, _, _ = cv2.minMaxLoc(r) scores.append((mv, fn)) if scores: best_v = max(s[0] for s in scores) best_m = max(scores, key=lambda s: s[0])[1] print(f" 验证码模板匹配: {best_m}={best_v:.3f}") captcha_kw = any("拖动滑块" in r["text"] or "请按住滑块" in r["text"] or "安全验证" in r["text"] for r in check_raw) nine_kw = any("提交" in r["text"] or "没有新图片" in r["text"] for r in check_raw) if best_v >= 0.30 and (captcha_kw or nine_kw): print(f" ⚠ 检测到验证码,尝试自动处理...") if _handle_captcha(ex, [r["text"] for r in check_raw]): continue return "__TERMINATE__" elif best_v >= 0.30 and not captcha_kw: print(f" ⚠ 模板匹配命中但OCR无验证码关键词,忽略") page_type = AIParser().check_page(check_raw) ptype = page_type.get("type", "unknown") if ptype == "risk": # OCR文字二次确认:店铺页/列表页文字多但无验证码特征词 → 误判,忽略 if not _has_captcha_kw([r["text"] for r in check_raw]): print(" AI判risk但OCR无验证码特征词,忽略(店铺页/列表页误判)") continue print(f" ⚠ AI检测到验证码,尝试自动处理...") if _handle_captcha(ex, [r["text"] for r in check_raw]): continue return "__TERMINATE__" if ptype == "home": # 二次确认:截图太早页面没加载完时AI可能误判首页(底部导航"我的"在任何页面都可见) time.sleep(1) ex.driver.screenshot(shot_check) confirm_raw = OCR.recognize(shot_check, detail="all") ptype2 = AIParser().check_page(confirm_raw).get("type", "unknown") if ptype2 == "home": print(" ⚠ 二次确认仍为首页(被踢回/退过头),触发重启恢复") return "__RESTART__" if ptype2 == "normal": break # 页面加载完成,恢复正常 print(f" AI先判home,二次确认为{ptype2},继续检测") continue if ptype == "list": print(" ⚠ AI检测到列表页(未成功进店),放弃本店") return "" if ptype == "login": global ACCOUNT_ABORTED ACCOUNT_ABORTED = True print(" ⚠ AI检测到登录页(账号被踢/封号),停止采集") return "__TERMINATE__" if ptype == "normal": break # 正常,跳出重试循环 # qrcode/unknown:等1秒二次截图确认(点击后立即截图可能页面没加载完,避免误判) time.sleep(1) ex.driver.screenshot(shot_check) confirm_raw = OCR.recognize(shot_check, detail="all") ptype2 = AIParser().check_page(confirm_raw).get("type", "unknown") if ptype2 == "normal": break if ptype2 == "risk": if not _has_captcha_kw([r["text"] for r in confirm_raw]): print(" 二次确认risk但OCR无验证码特征词,忽略") continue if _handle_captcha(ex, [r["text"] for r in confirm_raw]): continue return "__TERMINATE__" if ptype2 == "home": print(" ⚠ 二次确认检测到首页,触发重启恢复") return "__RESTART__" if ptype2 == "list": print(" ⚠ 二次确认检测到列表页,放弃本店") return "" if ptype2 == "login": ACCOUNT_ABORTED = True # 本函数已声明global print(" ⚠ 二次确认检测到登录页(账号被踢/封号),停止采集") return "__TERMINATE__" # 两次都是unknown → 确认计数 unknown_round += 1 print(f" 确认unknown(第{unknown_round}次)") if unknown_round >= 3: if not backed_once: # 3次确认unknown:back一层 + 重新点击进入一次(类似刷新) print(" 3次确认unknown,back一层并重新点击进入") ex.driver.press("back") time.sleep(1.5) if shop_xy: ex.tap(shop_xy[0], shop_xy[1]) time.sleep(2.5) backed_once = True unknown_round = 0 else: # 刷新后仍unknown → 重启恢复 import shutil err_dir = SCREENSHOT_DIR / ex.device_id / "step4" / "unrecognized" err_dir.mkdir(exist_ok=True) shutil.copy(shot_check, str(err_dir / f"unknown_{int(time.time())}.png")) print(" ⚠ 刷新后仍unknown,触发重启恢复") return "__RESTART__" time.sleep(2) else: # 检测循环耗尽仍未正常 → 重启恢复 import shutil err_dir = SCREENSHOT_DIR / ex.device_id / "step4" / "unrecognized" err_dir.mkdir(exist_ok=True) shutil.copy(shot_check, str(err_dir / f"unknown_{int(time.time())}.png")) print(f" ⚠ 检测循环耗尽({ptype}),触发重启恢复") return "__RESTART__" # normal → 继续 # ── 店铺页判断:OCR同时检测到「刚刚搜过」和「评价」说明在店铺页 ── in_shop = False for _ in range(10): shop_check = _pfx("shop_check.png") ex.driver.screenshot(shop_check) shop_raw = OCR.recognize(shop_check, detail="text") has_ganggang = any("刚刚搜过" in t for t in shop_raw) has_pingjia = any("评价" in t for t in shop_raw) if has_ganggang and has_pingjia: in_shop = True print(f" 已确认在店铺页") break time.sleep(1) if not in_shop: print(f" ⚠ 未检测到店铺页,继续尝试...") # ── 第1步:截图 + AI找商品标题坐标(AI失败或返回None时重试3次,页面可能未加载完)── system_prompt = """你收到店铺页的OCR文字。商品标题文字坐标已知(从OCR中有x,y)。 请找到和以下商品标题匹配的文字块,返回其点击坐标。 【重要规则】 - 坐标必须从OCR数据中选取,不得编造或估算 - 如果找不到完全匹配的,找最相似的 - 如果完全找不到,返回null 只返回JSON: {"title_xy": [x, y] 或 null, "shop": "店铺名"}""" parser = AIParser() title_xy = None for ai_try in range(3): shot = _pfx("shop.png") ex.driver.screenshot(shot) raw = OCR.recognize(shot, detail="all") sorted_r = sorted(raw, key=lambda r: r["bbox"][0][1]) lines = [] for r in sorted_r: cx = (r["bbox"][0][0] + r["bbox"][2][0]) // 2 cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2 lines.append(f"[x={cx:4d}, y={cy:4d}] {r['text']}") ocr_text = "\n".join(lines) resp = parser._call(system_prompt, f"商品标题: {product_title}\n\nOCR文字:\n{ocr_text}\n\n请返回商品标题坐标。") import json 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) title_xy = data.get("title_xy") except json.JSONDecodeError: title_xy = None if title_xy and isinstance(title_xy, list) and len(title_xy) == 2: break print(f" ⚠ AI未返回有效坐标(第{ai_try+1}次): {title_xy},2秒后重试") time.sleep(2) if not title_xy or not isinstance(title_xy, list) or len(title_xy) != 2: print(f" ⚠ AI 3次均未返回有效坐标: {title_xy}") return "" tx, ty = title_xy if tx is None or ty is None: print(f" ⚠ AI返回空坐标") return "" tx, ty = int(tx), int(ty) w, h = ex.driver.window_size() if not (0 <= tx <= w and 0 <= ty <= h): print(f" ⚠ 坐标越界: ({tx},{ty}) 超出屏幕 {w}x{h}") return "" # ── 第2步:点击商品标题 → 进入商品详情(最多重试3次)── entered_detail = False for attempt in range(3): print(f" 点击商品标题: ({tx},{ty}) (第{attempt+1}次)") ex.tap(tx, ty) time.sleep(3) # 点击后先等页面响应(验证码处理后/网络慢时切换慢) # 检测是否进入商品详情页(验证码检测优先——验证码页文字块少,不能被"加载中"分支挡掉) for _ in range(8): time.sleep(2) detail_check = _pfx("detail_check.png") ex.driver.screenshot(detail_check) detail_raw = OCR.recognize(detail_check, detail="all") detail_texts = [r["text"] for r in detail_raw] # 1. 验证码优先检测:验证码页文字块少(可能≤8),必须先于"加载中"判断 captcha_kw = any("拖动滑块" in t or "请按住滑块" in t or "安全验证" in t for t in detail_texts) if captcha_kw: print(f" ⚠ 检测到验证码页面,尝试自动处理...") if _handle_captcha(ex, detail_texts): continue return "__TERMINATE__" # 1.5 「重新加载」页(出错了/检修中,验证码通过后常见):点重新加载后继续等 if any("重新加载" in t for t in detail_texts): for r in detail_raw: if "重新加载" in r["text"]: box = r["bbox"] bx = (box[0][0] + box[2][0]) // 2 by = (box[0][1] + box[2][1]) // 2 print(f" 检测到「重新加载」页,点击重新加载 ({bx},{by})") ex.tap(bx, by) time.sleep(2) break continue # 2. 页面加载中/切换中(文字少且无验证码)→ 继续等待,不误判 if len(detail_texts) <= 8: print(f" 页面未就绪(仅{len(detail_texts)}块文字),继续等待...") continue # 2.5 检测是否退回首页/列表页(点标题失败/back过头时常见)→ 立即处理,不盲等 joined_texts = "".join(detail_texts) if ("看病买药" in joined_texts) and ("我的" in joined_texts): print(" ⚠ 检测到已退回首页,触发重启恢复") return "__RESTART__" if "筛选" in joined_texts: print(" ⚠ 检测到已回列表页,放弃本店(点标题未成功进店)") return "" # 3. 检测商品详情页关键词 if any("加入购物车" in t or "立即购买" in t or "选规格" in t or "商品详情页" in t for t in detail_texts): print(f" 已进入商品详情页") entered_detail = True break # 4. 不在详情页,检测是否还在店铺页 has_ganggang = any("刚刚搜过" in t for t in detail_texts) has_pingjia = any("评价" in t for t in detail_texts) if has_ganggang and has_pingjia: print(f" 仍在店铺页,重试...") break # 跳出内层循环 if entered_detail: break # 内层循环结束仍未进入详情页:AI判断当前实际页面类型(诊断 + 验证码/首页/列表页兜底) try: last_shot = _pfx("detail_check.png") if cv2.imread(last_shot) is not None: ai_raw = OCR.recognize(last_shot, detail="all") ai_page = AIParser().check_page(ai_raw) print(f" AI页面类型: {ai_page.get('type')} - {ai_page.get('detail', '')[:40]}") if ai_page.get("type") == "risk": if not _has_captcha_kw([r["text"] for r in ai_raw]): print(" AI判risk但OCR无验证码特征词,忽略") else: print(" ⚠ AI检测到验证码页,尝试自动处理...") if _handle_captcha(ex, [r["text"] for r in ai_raw]): continue return "__TERMINATE__" if ai_page.get("type") == "home": print(" ⚠ AI检测到首页,触发重启恢复") return "__RESTART__" if ai_page.get("type") == "list": print(" ⚠ AI检测到列表页,放弃本店") return "" if ai_page.get("type") == "login": ACCOUNT_ABORTED = True # 本函数已声明global print(" ⚠ AI检测到登录页(账号被踢/封号),停止采集") return "__TERMINATE__" except Exception as e: print(f" ⚠ AI页面类型检测异常: {e}") # 第1次失败后,重新OCR+AI获取坐标(可能是页面滚动导致坐标偏移) if attempt < 2: print(f" 重新OCR获取坐标...") re_shot = _pfx("shop.png") ex.driver.screenshot(re_shot) re_raw = OCR.recognize(re_shot, detail="all") re_sorted = sorted(re_raw, key=lambda r: r["bbox"][0][1]) re_lines = [] for r in re_sorted: r_cx = (r["bbox"][0][0] + r["bbox"][2][0]) // 2 r_cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2 re_lines.append(f"[x={r_cx:4d}, y={r_cy:4d}] {r['text']}") re_ocr_text = "\n".join(re_lines) re_resp = parser._call(system_prompt, f"商品标题: {product_title}\n\nOCR文字:\n{re_ocr_text}\n\n请返回商品标题坐标。") re_cleaned = re_resp.strip() if re_cleaned.startswith("```"): rl = re_cleaned.split("\n") if rl[0].startswith("```"): rl = rl[1:] if rl and rl[-1].strip() == "```": rl = rl[:-1] re_cleaned = "\n".join(rl).strip() try: re_data = json.loads(re_cleaned) re_xy = re_data.get("title_xy") if re_xy and isinstance(re_xy, list) and len(re_xy) == 2 and re_xy[0] is not None: tx, ty = int(re_xy[0]), int(re_xy[1]) ww, hh = ex.driver.window_size() if not (0 <= tx <= ww and 0 <= ty <= hh): print(f" ⚠ 新坐标越界: ({tx},{ty}),保持原坐标") else: print(f" 新坐标: ({tx},{ty})") except Exception: pass else: pass # 3次重试结束 if not entered_detail: print(f" ⚠ 3次点击未进入商品详情页,跳过") return "" # ── 第3步:ORB特征匹配找分享图标 ── share_shot = _pfx("find_share.png") ex.driver.screenshot(share_shot) screen = cv2.imread(share_shot) template_path = str(Path(__file__).parent / "files" / "share.png") template = cv2.imread(template_path) sx, sy = None, None if screen is not None and template is not None: h_s, w_s = screen.shape[:2] # 右上角区域(分享图标永远在右上) roi_x1, roi_y1 = w_s * 2 // 3, 0 roi = screen[roi_y1:h_s // 4, roi_x1:w_s] # 方法A: SIFT 特征匹配(限制右上角区域,减少干扰) sx, sy = None, None sift = cv2.SIFT_create(nfeatures=1500) kp1, des1 = sift.detectAndCompute(template, None) kp2, des2 = sift.detectAndCompute(roi, None) if des1 is not None and des2 is not None and len(kp1) >= 2 and len(kp2) >= 2: bf = cv2.BFMatcher() matches = bf.knnMatch(des1, des2, k=2) good = [] for m, n in matches: if m.distance < 0.75 * n.distance: good.append(m) print(f" 分享SIFT(右上区域): 模板{len(kp1)}特征 ROI{len(kp2)}特征 优质{len(good)}") if len(good) >= 4: src_pts = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2) dst_pts = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2) matrix, _ = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0) if matrix is not None: h_t, w_t = template.shape[:2] corners = np.float32([[0, 0], [w_t, 0], [w_t, h_t], [0, h_t]]).reshape(-1, 1, 2) transformed = cv2.perspectiveTransform(corners, matrix) sx = roi_x1 + int(np.mean(transformed[:, 0, 0])) sy = int(np.mean(transformed[:, 0, 1])) print(f" 分享SIFT匹配: ({sx},{sy})") # 方法B: 多尺度模板匹配(右上角区域) # 分辨率适配:以 720p 为基准,按屏幕宽度比例调整搜索尺度,覆盖 480p~1080p base_w = 720.0 scale_ratio = w_s / base_w # 模板在 720p 下约占 11% 屏宽,目标尺度应使模板覆盖相同比例 scales = [round(scale_ratio * s, 2) for s in [0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5]] if sx is None: best_val, best_loc, best_sw, best_sh = 0, None, 0, 0 for scale in scales: scaled = cv2.resize(template, None, fx=scale, fy=scale) sw, sh = scaled.shape[1], scaled.shape[0] if sh > roi.shape[0] or sw > roi.shape[1]: continue res = cv2.matchTemplate(roi, scaled, cv2.TM_CCOEFF_NORMED) _, mv, _, ml = cv2.minMaxLoc(res) if mv > best_val: best_val, best_loc, best_sw, best_sh = mv, ml, sw, sh t_edge = cv2.Canny(scaled, 30, 100) r_edge = cv2.Canny(roi, 30, 100) if t_edge.shape[0] <= r_edge.shape[0] and t_edge.shape[1] <= r_edge.shape[1]: res2 = cv2.matchTemplate(r_edge, t_edge, cv2.TM_CCOEFF_NORMED) _, mv2, _, ml2 = cv2.minMaxLoc(res2) if mv2 > best_val: best_val, best_loc, best_sw, best_sh = mv2, ml2, sw, sh print(f" 分享模板匹配(右上): 最佳={best_val:.3f}") if best_val >= 0.26 and best_loc is not None: sx = roi_x1 + best_loc[0] + best_sw // 2 sy = best_loc[1] + best_sh // 2 if sx is not None and sy is not None: print(f" 分享图标: ({sx},{sy})") ex.tap(sx, sy) else: print(f" ⚠ 未找到分享图标") return "" # 等弹窗出现,同时记录"分享到"y坐标用于QR裁剪 share_y = None waimai_y = None for _ in range(8): time.sleep(1) ck = _pfx("share_popup.png") ex.driver.screenshot(ck) detail = OCR.recognize(ck, detail="all") texts = [r["text"] for r in detail] if any("分享到" in t for t in texts): print(f" 分享弹窗出现") # 记录"分享到"和"外卖"的y坐标 for r in detail: cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2 if "分享到" in r["text"] and share_y is None: share_y = cy if "外卖" in r["text"] and waimai_y is None: waimai_y = cy break if share_y is None: share_y = int(ex.driver.window_size()[1] * 0.74) # fallback # ── 第4步:截图 → 多方法解析二维码(多次重试) ── import os as _os_debug _debug_dir = str(SCREENSHOT_DIR / ex.device_id / "step4" / "debug_qr") _os_debug.makedirs(_debug_dir, exist_ok=True) # 微信QR解码器(对ECI编码的饿了么QR鲁棒,实测成功率94%) _wx_detector = cv2.wechat_qrcode.WeChatQRCode() if hasattr(cv2, "wechat_qrcode") else None def _decode_qr(img, share_y, waimai_y=None): """基于OCR定位的share_y裁剪QR区域解析""" if img is None: return "" h, w = img.shape[:2] gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) detector = cv2.QRCodeDetector() # 裁剪区域:y从"分享到"上方推算(不依赖"外卖"文字,避免OCR误判) # QR 通常在"分享到"上方 15%~35% 屏高范围,取中间偏下 y_top = max(0, share_y - int(h * 0.30)) y_bot = share_y x_l, x_r = int(w * 0.58), int(w * 0.94) crop_save = img[y_top:y_bot, x_l:x_r] cv2.imwrite(_pfx("qr_crop.png"), crop_save) # 保存调试截图:标注裁剪区域 + QR 边界(按设备ID区分,方便对比不同分辨率) debug_img = img.copy() cv2.rectangle(debug_img, (x_l, y_top), (x_r, y_bot), (0, 255, 0), 3) cv2.putText(debug_img, f"crop:({x_l},{y_top})~({x_r},{y_bot})", (x_l, y_top - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) _ts = int(time.time()) cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_region_{_ts}.png", debug_img) # 方法0: 微信QR解码器(对ECI编码的饿了么QR鲁棒,实测94%成功率,全图直接解析) if _wx_detector is not None: try: wx_texts, wx_points = _wx_detector.detectAndDecode(img) if wx_texts and wx_texts[0]: data = wx_texts[0] if wx_points is not None and len(wx_points) > 0: wp = wx_points[0].astype(int) x1, y1 = wp[:, 0].min(), wp[:, 1].min() x2, y2 = wp[:, 0].max(), wp[:, 1].max() cv2.rectangle(debug_img, (x1, y1), (x2, y2), (0, 0, 255), 3) cv2.putText(debug_img, "WX-QR", (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2) cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img) return data except Exception: pass def _try_decode(roi_gray, zooms=(1,)): """在灰度图上尝试多种方式解码""" if roi_gray is None or roi_gray.size == 0 or roi_gray.shape[0] == 0 or roi_gray.shape[1] == 0: return "" # 高分辨率下 QR 可能太大导致 detect 失败,先缩到合理尺寸再解析 h_roi, w_roi = roi_gray.shape[:2] if max(h_roi, w_roi) > 500: scale_down = 500 / max(h_roi, w_roi) roi_small = cv2.resize(roi_gray, None, fx=scale_down, fy=scale_down, interpolation=cv2.INTER_AREA) else: roi_small = roi_gray for z in zooms: if z > 1: big = cv2.resize(roi_small, None, fx=z, fy=z, interpolation=cv2.INTER_NEAREST) else: big = roi_small data, _, _ = detector.detectAndDecode(big) if data: return data # OTSU + zoom for z in zooms: _, th = cv2.threshold(roi_small, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) big = cv2.resize(th, None, fx=z, fy=z, interpolation=cv2.INTER_NEAREST) if z > 1 else th data, _, _ = detector.detectAndDecode(big) if data: return data return "" # 方法A: 全图detect定位QR → 中心 ±qr_half 精确裁剪解析(qr_half 随分辨率缩放) # QR 在全图里 detect 可能失败(干扰太多),但先试试 ok, points = detector.detect(gray) if ok and points is not None and len(points) > 0: pts = points[0].astype(int) cx = int(np.mean(pts[:, 0])) cy = int(np.mean(pts[:, 1])) # 根据 detect 到的 QR 边界估算大小,裁剪 QR 中心 ± qr_half qr_half = max(int(max(np.linalg.norm(pts[0] - pts[1]), np.linalg.norm(pts[1] - pts[2])) / 2) + 20, 60) x1, y1 = max(0, cx - qr_half), max(0, cy - qr_half) x2, y2 = min(w, cx + qr_half), min(h, cy + qr_half) if x2 > x1 and y2 > y1: data = _try_decode(gray[y1:y2, x1:x2], (1, 2, 3)) if data: # 在调试截图上标注 QR 检测位置 cv2.rectangle(debug_img, (x1, y1), (x2, y2), (0, 0, 255), 3) cv2.putText(debug_img, f"QR:({cx},{cy})", (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2) cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img) return data # 方法B: 用 detector.detect 在扫描区域内定位 QR → 中心 ± qr_half 精确裁剪解析 scan_area = gray[y_top:y_bot, x_l:x_r] sh, sw = scan_area.shape ok2, pts2 = detector.detect(scan_area) if ok2 and pts2 is not None and len(pts2) > 0: qr_pts = pts2[0].astype(int) qx = int(np.mean(qr_pts[:, 0])) qy = int(np.mean(qr_pts[:, 1])) qr_half = max(int(max(np.linalg.norm(qr_pts[0] - qr_pts[1]), np.linalg.norm(qr_pts[1] - qr_pts[2])) / 2) + 20, 60) qx1, qy1 = max(0, qx - qr_half), max(0, qy - qr_half) qx2, qy2 = min(sw, qx + qr_half), min(sh, qy + qr_half) if qx2 > qx1 and qy2 > qy1: data = _try_decode(scan_area[qy1:qy2, qx1:qx2], (1, 2, 3)) if data: cv2.rectangle(debug_img, (x_l + qx1, y_top + qy1), (x_l + qx2, y_top + qy2), (0, 0, 255), 3) cv2.putText(debug_img, f"QR:({x_l + qx},{y_top + qy})", (x_l + qx1, y_top + qy1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2) cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img) return data # 方法C: 滑动窗口扫描(基于OCR定位区域,尺寸随分辨率缩放) win = max(40, min(200, sh // 2, sw // 2)) step = max(40, win // 3) for y in range(0, max(1, sh - win), step): for x in range(0, max(1, sw - win), step): patch = scan_area[y:y+win, x:x+win] data = _try_decode(patch, (1, 2)) if data: # 在调试截图上标注命中的窗口位置 cv2.rectangle(debug_img, (x_l + x, y_top + y), (x_l + x + win, y_top + y + win), (0, 0, 255), 3) cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img) return data # 方法D: 全图 OTSU + detect → 中心 ± qr_half 精确裁剪解析 _, full_th = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) ok3, pts3 = detector.detect(full_th) if ok3 and pts3 is not None and len(pts3) > 0: qr_pts = pts3[0].astype(int) qx = int(np.mean(qr_pts[:, 0])) qy = int(np.mean(qr_pts[:, 1])) qr_half = max(int(max(np.linalg.norm(qr_pts[0] - qr_pts[1]), np.linalg.norm(qr_pts[1] - qr_pts[2])) / 2) + 20, 60) qx1, qy1 = max(0, qx - qr_half), max(0, qy - qr_half) qx2, qy2 = min(w, qx + qr_half), min(h, qy + qr_half) if qx2 > qx1 and qy2 > qy1: data = _try_decode(gray[qy1:qy2, qx1:qx2], (1, 2, 3)) if data: cv2.rectangle(debug_img, (qx1, qy1), (qx2, qy2), (0, 0, 255), 3) cv2.putText(debug_img, f"QR:({qx},{qy})", (qx1, qy1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2) cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img) return data return "" for retry in range(6): # 最多等 5+2*5=15秒 time.sleep(5 if retry == 0 else 2) qr_shot = _pfx("qr.png") ex.driver.screenshot(qr_shot) data = _decode_qr(cv2.imread(qr_shot), share_y, waimai_y) if data: print(f" QR链接: {data[:100]}") return data return "" # ── 步骤 3:滑动 + 逐个点击店铺 ────────────────────── def _progress_file_path(device_id: str, keyword: str) -> str: """进度文件路径(美团同款:ycwj/{设备}_{药品}.txt)""" import hashlib safe = hashlib.md5(keyword.encode()).hexdigest()[:8] return str(Path(__file__).parent / "ycwj" / f"{device_id}_{safe}.txt") def _save_progress(device_id: str, keyword: str, visited: set, scroll_px: int, batch_no: int) -> None: """保存采集进度(每批滑动后调用,异常退出时进度已在)""" import os try: path = _progress_file_path(device_id, keyword) os.makedirs(os.path.dirname(path), exist_ok=True) data = { "visited": sorted(visited), "scroll_px": scroll_px, "batch_no": batch_no, "time": time.strftime("%Y-%m-%d %H:%M:%S"), } with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) except Exception as e: print(f"[step3] 保存进度失败: {e}") def _load_progress(device_id: str, keyword: str): """读取采集进度,无进度文件返回 None""" import os try: path = _progress_file_path(device_id, keyword) if not os.path.exists(path): return None with open(path, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: print(f"[step3] 读取进度失败: {e}") return None def _delete_progress(device_id: str, keyword: str) -> None: """任务正常完成后删除进度文件""" import os try: path = _progress_file_path(device_id, keyword) if os.path.exists(path): os.remove(path) print(f"[step3] 进度文件已删除: {path}") except Exception as e: print(f"[step3] 删除进度失败: {e}") def _spec_ok(title: str, spec_list: list) -> bool: """ 标题是否包含任一目标规格(美团 is_link_spec_useful 同款)。 规格是数字+单位,OCR对数字错误率极低,用精确匹配(模糊匹配会把"10袋"误配到"10g")。 """ if not spec_list: return True nt = normalize_match_text(title) return any(normalize_match_text(s) in nt for s in spec_list) def step3_swipe_and_enter(ex: SafeExecutor, keyword: str, brand: str = "", task: dict = None, scheduler=None) -> list: """ 截图 → AI分析 → 逐个点击全部可见店铺 → 下滑加载更多 → 继续点击 → 直到全部遍历 品牌+药品名过滤(美团 is_link_useful 同款):标题必须同时包含品牌名和药品名, 否则过滤;连续30个无关商品则任务结束停止采集。 task: 调度任务 dict(task_id/enterprise_id/collect_round/current_page等,手动模式传 None) scheduler: 调度器(逐页回告进度,手动模式传 None) """ print("\n" + "=" * 40) print(" 步骤 3:遍历店铺") print("=" * 40) # 新任务开始:重置所有停止标志和缓存(上个任务的验证码/封号停止不能污染本任务) global _CLOSE_BTN_CACHE, CAPTCHA_ABORTED, CAPTCHA_ABORT_REASON, ACCOUNT_ABORTED, CURRENT_PAGE global _SCHEDULER, _TASK_ID, _CRAWLED_COUNT _CLOSE_BTN_CACHE = None CAPTCHA_ABORTED = False CAPTCHA_ABORT_REASON = "" ACCOUNT_ABORTED = False CURRENT_PAGE = 0 _SCHEDULER = scheduler _TASK_ID = (task or {}).get("task_id") _CRAWLED_COUNT = 0 w, h = ex.driver.window_size() batch_no = 0 empty_streak = 0 # 连续没有新店铺的批次数 all_results = [] unrelated = 0 # 连续无关商品计数(美团同款;>=30 停止采集) n_brand = normalize_match_text(brand) # 目标规格(调度任务/手动 --spec 传入,如 "120粒|60粒"),用于规格过滤+入库 spec_raw = str((task or {}).get("product_specs") or "") spec_list = (task or {}).get("spec_list") or [] if isinstance(spec_list, str): # 兼容直接传字符串 spec_list = [s.strip() for s in re.split(r'[|、,,\n\r]+', spec_list) if s.strip()] n_key = normalize_match_text(keyword) stopped = False # 恢复进度: # 1. 跨设备接力:task 带 current_page(调度重派时给)→ 按页码滑动恢复(每页=0.7屏高,跨分辨率一致) # 2. 同设备异常恢复:本地进度文件(visited + 滑动px,精确恢复) start_page = int((task or {}).get("current_page") or 0) progress = _load_progress(ex.device_id, keyword) visited = set() total_scroll_px = 0 if start_page > 0: print(f"[step3] 跨设备接力恢复: 调度页码={start_page},滑动{start_page}页...") for i in range(start_page): _adb_swipe_up(ex, int(h * 0.7)) time.sleep(2) elif progress: visited = set(progress.get("visited") or []) total_scroll_px = int(progress.get("scroll_px") or 0) print(f"[step3] 恢复进度: 已访问{len(visited)}个店铺,需滑动恢复{total_scroll_px}px") remain = total_scroll_px while remain > 0: step = min(remain, int(h * 0.5)) _adb_swipe_up(ex, step) remain -= step time.sleep(2) else: visited = set() MAX_RESTART = 3 # unknown页面异常时最大重启恢复次数(超过则终止任务) restart_count = 0 while True: if CAPTCHA_ABORTED: print(f"[step3] {CAPTCHA_ABORT_REASON or chr(39)+chr(39)}停止采集") break _check_account_kicked(ex) # 每批检测账号是否被踢/封号(xpath检查,开销小) if ACCOUNT_ABORTED: print("[step3] 账号被踢/封号,停止采集") break named, raw_local = _get_named_shops(ex, f"step3_b{batch_no}.png", keyword) if _ai_check_captcha(ex): # 列表页每批检测验证码(风控弹窗可能出现在列表) time.sleep(1) named, raw_local = _get_named_shops(ex, f"step3_b{batch_no}.png", keyword) # 验证码处理后重新识别 raw_new = [s for s in named if _shop_key(s) not in visited] # 品牌+药品名+规格过滤(测试OCR准确率时可关闭:ENABLE_TITLE_FILTER=False 直接采集全部结果) if not ENABLE_TITLE_FILTER: # 测试模式:不过滤,直接采集OCR识别到的全部结果 new_ones = list(raw_new) else: cloud_cache = {"done": False, "texts": None} shot_path = _shot_path(ex, f"step3_b{batch_no}.png") new_ones = [] for s in raw_new: # 裁剪确认用标题块自己的坐标(卡片中心裁剪会漏掉标题行) title_y = _find_title_y(raw_local, str(s[1] or "")) if title_y is None and len(s) > 3 and s[3]: title_y = int(s[3][1]) v, v_reason = _match_verify(str(s[1] or ""), n_brand, n_key, shot_path, cloud_cache, card_y=title_y) if v in ("ok", "fuzzy"): # 规格过滤(美团 is_link_spec_useful 同款):标题需包含任一目标规格 if spec_list and not _spec_ok(str(s[1] or ""), spec_list): unrelated += 1 print(f"[step3] 过滤: {str(s[1])[:30]} 不含目标规格{spec_list} (连续{unrelated}个无关)") if unrelated >= 30: print(f"[step3] 连续{unrelated}个非目标商品,任务结束停止采集") stopped = True break continue unrelated = 0 # 云端验证过:生僻字修正开关开启时才用云端文本修正标题错字(默认关闭,标题保持OCR原样) if v == "ok" and ENABLE_RARE_CHAR_CORRECT and cloud_cache["texts"] is not None: s = list(s) s[1] = _correct_title_with_cloud(str(s[1]), n_brand, n_key, cloud_cache["texts"]) new_ones.append(s) else: unrelated += 1 print(f"[step3] 过滤: {str(s[1])[:30]} 不含目标{v_reason} (连续{unrelated}个无关)") if unrelated >= 30: print(f"[step3] 连续{unrelated}个非目标商品,任务结束停止采集") stopped = True break if stopped: print("[step3] 停止采集,返回已采结果") break print(f"[step3] 批次{batch_no}: 共{len(named)}个, 新{len(raw_new)}个, 通过过滤{len(new_ones)}个") # 逐页回告调度:页码 = 起点页码 + 批次数(每滑一屏算一页;global已在任务开头声明) CURRENT_PAGE = start_page + batch_no _CRAWLED_COUNT = len(all_results) if scheduler is not None: try: scheduler.post_report({ "task_id": (task or {}).get("task_id"), "platform": scheduler.platform, "username": scheduler.username, "is_finished": 0, "need_reassign": 0, "current_page": start_page + batch_no, "crawled_count": len(all_results), }) except Exception as e: print(f"[step3] 逐页回告失败: {e}") if not new_ones: empty_streak += 1 print(f"[step3] 无新店铺 (连续{empty_streak}/3)") if empty_streak >= 3: print(f"[step3] 连续3批无新店铺,结束") break # 滑动后再试 print(f"[step3] 滑动查看下一批") if len(named) >= 2: target_y = named[-2][4] swipe_dist = max(target_y - int(h * 0.15), int(h * 0.15)) else: swipe_dist = int(h * 0.3) _adb_swipe_up(ex, swipe_dist) total_scroll_px += swipe_dist time.sleep(2) batch_no += 1 _save_progress(ex.device_id, keyword, visited, total_scroll_px, batch_no) continue empty_streak = 0 # 有新店铺,重置计数 restart_requested = False for shop in new_ones: result = _visit_shop(ex, shop, visited, keyword, task) if result and result.get("__terminate__"): print("[step3] 收到终止信号,停止遍历") all_results = [r for r in all_results if not r.get("__terminate__")] stopped = True break if result and result.get("__restart__"): restart_requested = True break if result: result["brand"] = brand result["product_specs"] = spec_raw all_results.append(result) save_record(result) # 每采完一个立即入库,中断不丢数据 if restart_requested: # unknown页面异常:重启App恢复(进度按页保存,visited保留在内存) restart_count += 1 if restart_count >= MAX_RESTART: print(f"[step3] 重启恢复超限({MAX_RESTART}次),终止任务") stopped = True break print(f"[step3] unknown页面异常,重启App恢复(第{restart_count}/{MAX_RESTART}次)") _save_progress(ex.device_id, keyword, visited, total_scroll_px, batch_no) step1_open_app(ex) step2_search(ex, (brand + keyword).strip() or keyword) # 从列表顶部滑动恢复到上次位置 remain = total_scroll_px while remain > 0: step = min(remain, int(h * 0.5)) _adb_swipe_up(ex, step) remain -= step time.sleep(2) continue if stopped: break # 正常:滑动到倒数第二个卡片的配送距离位置,进入下一批 print(f"[step3] 已访问 {len(visited)} 个,滑动查看下一批") if len(named) >= 2: target_y = named[-2][4] # 倒数第二个卡片的配送距离y坐标 swipe_dist = max(target_y - int(h * 0.15), int(h * 0.15)) else: swipe_dist = int(h * 0.3) _adb_swipe_up(ex, swipe_dist) total_scroll_px += swipe_dist time.sleep(2) batch_no += 1 _save_progress(ex.device_id, keyword, visited, total_scroll_px, batch_no) # 每页保存一次进度 continue # 任务结束:只有正常完成才删除进度文件;验证码/封号等异常停止时保留(续采位置不丢) if not CAPTCHA_ABORTED and not ACCOUNT_ABORTED: _delete_progress(ex.device_id, keyword) else: print(f"[step3] 异常停止,保留进度文件以便恢复: {_progress_file_path(ex.device_id, keyword)}") # ── 输出最终结果表 ── print("\n" + "=" * 70) print(f" 最终结果 ({len(all_results)} 个店铺)") print("=" * 70) for i, r in enumerate(all_results, 1): link_short = r["link"][:55] + "..." if len(r["link"]) > 55 else r["link"] print(f" [{i}] {r['shop']}") print(f" 商品: {r['title'][:40]}") print(f" 价格: {r['price']}") print(f" 月售: {r.get('sales', '')}") print(f" 批准文号: {r.get('approval_no', '')}") print(f" 有效期: {r.get('validity', '')}") print(f" 资质编号: {r.get('license_no', '')}") lic = r.get("license") or {} print(f" 执照: {lic.get('单位名称', '')} 信用代码:{lic.get('社会信用代码', '')} 法人:{lic.get('法人', '')}") print(f" 执照地址: {lic.get('地址', '')[:40]}") print(f" 链接: {link_short}") print() return all_results # ── 步骤 4:(保留,当前为空 ────────────────────────── def step4_empty(ex: SafeExecutor): """占位,供后续扩展""" pass # ── 主入口 ────────────────────────────────────────────── if __name__ == "__main__": # 启动时自动清理:debug_qr 调试图只保留1天 try: for p in SCREENSHOT_DIR.glob("*/step4/debug_qr/*.png"): if time.time() - p.stat().st_mtime > 86400: p.unlink(missing_ok=True) except Exception: pass args = sys.argv[1:] device_id = "RG5LFYT8UKK7BI95" brand = "三九胃泰" keyword = "养胃舒颗粒" spec_raw = "" spec_list = [] # 解析 --device / --brand / --keyword / --spec 参数(品牌和药品名分开传,对接调度系统) filtered = [] i = 0 while i < len(args): if args[i] == "--device" and i + 1 < len(args): device_id = args[i + 1] i += 2 elif args[i] == "--brand" and i + 1 < len(args): brand = args[i + 1] i += 2 elif args[i] == "--keyword" and i + 1 < len(args): keyword = args[i + 1] i += 2 elif args[i] == "--spec" and i + 1 < len(args): spec_raw = args[i + 1] spec_list = [s.strip() for s in re.split(r'[|、,,\n\r]+', spec_raw) if s.strip()] i += 2 else: filtered.append(args[i]) i += 1 cmd = filtered[0] if filtered else "all" if not keyword: keyword = filtered[1] if len(filtered) > 1 else "矿泉水" # 搜索词 = 品牌+药品名+规格 合起来(美团同款:分开配置,搜索时合并) search_key = (brand + keyword + spec_raw).strip() or keyword print(f"品牌: {brand or '(无)'} | 药品名: {keyword} | 规格: {spec_list or '(不限)'} | 搜索词: {search_key}") print("设备连接中...") device_id = _find_device(device_id) print(f"设备: {device_id}") ex = SafeExecutor(device_id) if cmd in ("all", "step1"): ok = step1_open_app(ex) if not ok: sys.exit(1) if cmd in ("all", "step2"): ok = step2_search(ex, search_key) if not ok: sys.exit(1) if cmd in ("all", "step3"): task = {"spec_list": spec_list, "product_specs": spec_raw} visited = step3_swipe_and_enter(ex, keyword, brand, task) print(f"\n最终访问: {visited}") sys.exit(0) if cmd in ("all", "step4"): # step4 需要先跑完 step3 获取所有商品标题,单独跑时需要手动传标题 title = keyword link = step4_parse_qr(ex, title) print(f"\n链接: {link}") sys.exit(0) sys.exit(0)