""" 饿了么闪购 — 主入口 V1 步骤驱动,按步执行 """ import sys import time import re 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_helper import AIParser # ── 配置 ──────────────────────────────────────────────── APP_PACKAGE = "me.ele" SCREENSHOT_DIR = Path(__file__).parent / "screenshots" OCR = OCR() 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 _screenshot(ex: SafeExecutor, name: str) -> str: import os # 方案1:按设备 ID 隔离截图,避免多设备并发时写同一个文件 name = f"{ex.device_id}_{name}" SCREENSHOT_DIR.mkdir(exist_ok=True) path = str(SCREENSHOT_DIR / name) if os.path.exists(path): # 保留历史截图副本:固定文件名被 test/调试脚本引用,不能被后续运行覆盖丢失 import shutil stem, ext = os.path.splitext(name) backup = str(SCREENSHOT_DIR / f"{stem}_{int(time.time() * 1000)}{ext}") try: shutil.copy2(path, backup) 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 _is_search_page(ex: SafeExecutor) -> bool: """判断当前是否在搜索页面:只检测屏幕顶部20%区域内是否有「筛选」""" import tempfile, os w, h = ex.driver.window_size() tmp = str(SCREENSHOT_DIR / f"{ex.device_id}_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) shot = _screenshot(ex, "step1_home.png") texts = OCR.recognize(shot, rect=[0, int(h * 0.88), w, h], detail="text") print(f"[step1] 底部识别: {texts}") for t in texts: if "我的" in t: print("[step1] OK - 成功进入 App") return True print("[step1] FAIL - 未检测到「我的」") return False # ── 步骤 2:搜索商品 ──────────────────────────────────── 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:点击「看病买药」 ── shot0 = _screenshot(ex, "step2_phase0.png") btn_med = _find_text_in_area(shot0, "看病买药", h) if not btn_med: print("[step2] FAIL - 未找到「看病买药」") return False print(f"[step2] 找到「看病买药」: ({btn_med['x']}, {btn_med['y']})") ex.tap(btn_med["x"], btn_med["y"]) time.sleep(3) # ── 阶段1:点击首页搜索栏 ── shot = _screenshot(ex, "step2_phase1.png") btn = _find_text_in_area(shot, "搜索", top_th) if not btn: print("[step2] FAIL - 未找到「搜索」") return False print(f"[step2] 找到「搜索」: ({btn['x']}, {btn['y']})") cx = btn["x"] - 120 # 搜索左边约120px cy = btn["y"] ex.tap(cx, cy) time.sleep(3) shot2 = _screenshot(ex, "step2_phase2.png") btn2 = _find_text_in_area(shot2, "搜索", top_th) if not btn2: print("[step2] FAIL - 进入搜索页后找不到「搜索」") return False moved = abs(btn2["x"] - btn["x"]) > 50 or abs(btn2["y"] - btn["y"]) > 50 if not moved: print("[step2] FAIL - 搜索位置未改变") return False print(f"[step2] 搜索页搜索: ({btn2['x']}, {btn2['y']})") 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) shot3 = _screenshot(ex, "step2_result.png") raw3 = OCR.recognize(shot3, detail="all") all_texts = [r["text"] for r in raw3] 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_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 if has_filter or has_express: print("[step2] OK - 搜索成功") return True print("[step2] FAIL - 搜索未成功") 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 _get_named_shops(ex: SafeExecutor, shot_name: str) -> list: """截图 + OCR + AI → 返回有店铺名的列表""" shot = _screenshot(ex, shot_name) raw = OCR.recognize(shot, detail="all") w, h = ex.driver.window_size() parser = AIParser() shops = parser.parse_shops(raw, screen_size=(w, h)) # 只保留有效店铺名+价格:店铺名必须含中文或字母(排除纯数字/标点/空格) 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 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) -> dict: """点击进入店铺 → 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] click_x, click_y = shop[3] print(f" → 进入 [{shop_name}] 商品: {product_title[:30]} 价格: {price}") ex.tap(click_x, click_y) try: qr_url = step4_parse_qr(ex, product_title, shop_name) except Exception as e: print(f" ⚠ step4异常: {e},跳过此店铺") qr_url = "" if qr_url == "__TERMINATE__": print(f" ⚠ 遇到终止信号,停止遍历") return {"__terminate__": True} if qr_url: print(f" ✅ QR: {qr_url[:80]}") print(f" 📦 采集完成: {shop_name} | {product_title[:30]} | {price} | {qr_url[:60]}") else: print(f" ⚠ 未获取到二维码链接") print(f" 📦 采集完成(无链接): {shop_name} | {product_title[:30]} | {price}") # 返回搜索页:最多退3次,每次检测顶部区域是否有「筛选」 for _ in range(3): ex.driver.press("back") time.sleep(1.4) if _is_search_page(ex): break return { "shop": shop_name, "title": product_title, "price": price, "link": qr_url or "", } def _handle_captcha(ex: SafeExecutor, ocr_texts: list) -> bool: """处理验证码, 重试5次, 失败等人工, 返回True=已解决""" import sys as _sys _sys.path.insert(0, r"D:\drug\sg\yzm") 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_test2 import solve_slider ok = solve_slider(ex.driver) if ok: print(f" ✅ 验证码已解决") return True print(f" ❌ 第{attempt}次失败") time.sleep(1) print(f" ⚠ 5次自动处理失败, 请人工处理...") input(" 处理完成后按回车继续...") return True def step4_parse_qr(ex: SafeExecutor, product_title: str, shop_name: str = "") -> 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: str(SCREENSHOT_DIR / f"_s4_{ex.device_id}_{_hash}_{name}") time.sleep(6) # ── 检测页面类型:验证码/风控/正常(unknown/qrcode 重试3次)── for page_retry in range(3): 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": print(f" ⚠ AI检测到验证码,尝试自动处理...") if _handle_captcha(ex, [r["text"] for r in check_raw]): continue return "__TERMINATE__" if ptype == "normal": break # 正常,跳出重试循环 # qrcode 或 unknown → 可能未加载完成 if page_retry < 2: print(f" 检测到{ptype}页面,可能未加载完成,第{page_retry+1}次重试...") time.sleep(2) else: # 3次重试后仍异常 import shutil err_dir = SCREENSHOT_DIR / "unrecognized" err_dir.mkdir(exist_ok=True) shutil.copy(shot_check, str(err_dir / f"{ptype}_{int(time.time())}.png")) print(f" ⚠ 3次检测均为{ptype}页面,终止程序") return "__TERMINATE__" # 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找商品标题坐标 ── 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) system_prompt = """你收到店铺页的OCR文字。商品标题文字坐标已知(从OCR中有x,y)。 请找到和以下商品标题匹配的文字块,返回其点击坐标。 【重要规则】 - 坐标必须从OCR数据中选取,不得编造或估算 - 如果找不到完全匹配的,找最相似的 - 如果完全找不到,返回null 只返回JSON: {"title_xy": [x, y] 或 null, "shop": "店铺名"}""" parser = AIParser() 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 not title_xy or not isinstance(title_xy, list) or len(title_xy) != 2: print(f" ⚠ AI未返回有效坐标: {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) # 检测是否进入商品详情页 for _ in range(5): 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] # 检测商品详情页关键词 if any("加入购物车" in t or "立即购买" in t or "选规格" in t or "商品详情页" in t for t in detail_texts): print(f" 已进入商品详情页") entered_detail = True break # 检测验证码页面 captcha_kw = any("拖动滑块" in t or "请按住滑块" in t or "安全验证" in t for t in detail_texts) captcha_tpl = str(Path(__file__).parent / "files" / "captcha1.png") # tpl_match = False # if _os.path.exists(captcha_tpl): # si = cv2.imread(detail_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) # gs_crop = gs[crop_y1:crop_y2, 0:400] # best_v = 0 # 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) # best_v = max(best_v, mv) # tpl_match = best_v >= 0.30 if captcha_kw or tpl_match: print(f" ⚠ 检测到验证码页面,尝试自动处理...") if _handle_captcha(ex, detail_texts): continue return "__TERMINATE__" # 不在详情页,检测是否还在店铺页 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 # 第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: 多尺度模板匹配(右上角区域) if sx is None: best_val, best_loc, best_sw, best_sh = 0, None, 0, 0 for scale in [0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3]: 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步:截图 → 多方法解析二维码(多次重试) ── def _decode_qr(img, share_y): """基于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从"外卖"(或估计值)到"分享到", x从60%到92% y_top = waimai_y if waimai_y else max(0, share_y - 280) y_bot = share_y x_l, x_r = int(w * 0.60), int(w * 0.92) crop_save = img[y_top:y_bot, x_l:x_r] cv2.imwrite(_pfx("qr_crop.png"), crop_save) 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 "" for z in zooms: if z > 1: big = cv2.resize(roi_gray, None, fx=z, fy=z, interpolation=cv2.INTER_NEAREST) else: big = roi_gray data, _, _ = detector.detectAndDecode(big) if data: return data # OTSU + zoom for z in zooms: _, th = cv2.threshold(roi_gray, 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 → 裁200x200 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])) x1, y1 = max(0, cx - 100), max(0, cy - 100) x2, y2 = min(w, cx + 100), min(h, cy + 100) if x2 > x1 and y2 > y1: data = _try_decode(gray[y1:y2, x1:x2], (1, 2, 3)) if data: return data # 方法B: 200x200滑动窗口扫描(基于OCR定位区域) scan_area = gray[y_top:y_bot, x_l:x_r] sh, sw = scan_area.shape step = min(80, max(40, sh // 3, sw // 3)) for y in range(0, max(1, sh - 200), step): for x in range(0, max(1, sw - 200), step): patch = scan_area[y:y+200, x:x+200] data = _try_decode(patch, (1, 2)) if data: return data # 方法C: 固定区域 fallback(基于OCR定位) crop = gray[y_top:y_bot, x_l:x_r] data = _try_decode(crop, (1, 2, 3, 4)) if data: return data # 方法D: 全图兜底(裁剪失败时直接在全图上尝试) data = _try_decode(gray, (1, 2, 3)) if data: return data # 方法E: 全图 OTSU + 放大 _, full_th = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) for z in (1, 2, 3): big = cv2.resize(full_th, None, fx=z, fy=z, interpolation=cv2.INTER_CUBIC) if z > 1 else full_th data, _, _ = detector.detectAndDecode(big) if data: 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) if data: print(f" QR链接: {data[:100]}") return data return "" # ── 步骤 3:滑动 + 逐个点击店铺 ────────────────────── def step3_swipe_and_enter(ex: SafeExecutor, keyword: str) -> list: """ 截图 → AI分析 → 逐个点击全部可见店铺 → 下滑加载更多 → 继续点击 → 直到全部遍历 """ print("\n" + "=" * 40) print(" 步骤 3:遍历店铺") print("=" * 40) visited = set() w, h = ex.driver.window_size() batch_no = 0 empty_streak = 0 # 连续没有新店铺的批次数 all_results = [] while True: named = _get_named_shops(ex, f"step3_b{batch_no}.png") new_ones = [s for s in named if _shop_key(s) not in visited] print(f"[step3] 批次{batch_no}: 共{len(named)}个, 新{len(new_ones)}个") 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 = target_y - int(h * 0.15) if swipe_dist > 0: _adb_swipe_up(ex, swipe_dist) else: _adb_swipe_up(ex, int(h * 0.15)) else: _adb_swipe_up(ex, int(h * 0.3)) time.sleep(2) batch_no += 1 continue empty_streak = 0 # 有新店铺,重置计数 for shop in new_ones: result = _visit_shop(ex, shop, visited) if result and result.get("__terminate__"): print("[step3] 收到终止信号,停止遍历") all_results = [r for r in all_results if not r.get("__terminate__")] break if result: all_results.append(result) else: # for 正常结束 → 滑动到倒数第二个卡片的配送距离位置 print(f"[step3] 已访问 {len(visited)} 个,滑动查看下一批") if len(named) >= 2: target_y = named[-2][4] # 倒数第二个卡片的配送距离y坐标 swipe_dist = target_y - int(h * 0.15) if swipe_dist > 0: _adb_swipe_up(ex, swipe_dist) else: _adb_swipe_up(ex, int(h * 0.15)) else: _adb_swipe_up(ex, int(h * 0.3)) time.sleep(2) batch_no += 1 continue # break 出来 → 结束 break # ── 输出最终结果表 ── 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" 链接: {link_short}") print() return all_results # ── 步骤 4:(保留,当前为空 ────────────────────────── def step4_empty(ex: SafeExecutor): """占位,供后续扩展""" pass # ── 主入口 ────────────────────────────────────────────── if __name__ == "__main__": args = sys.argv[1:] device_id = "T4VK4LM7AAUOV8AY" # 解析 --device 参数 filtered = [] i = 0 while i < len(args): if args[i] == "--device" and i + 1 < len(args): device_id = args[i + 1] i += 2 else: filtered.append(args[i]) i += 1 cmd = filtered[0] if filtered else "all" keyword = filtered[1] if len(filtered) > 1 else "矿泉水" 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, keyword) if not ok: sys.exit(1) if cmd in ("all", "step3"): visited = step3_swipe_and_enter(ex, keyword) 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)