|
|
@@ -0,0 +1,920 @@
|
|
|
+"""
|
|
|
+饿了么闪购 — 主入口 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
|
|
|
+ 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
|
|
|
+ ex.driver.screenshot(path)
|
|
|
+ return path
|
|
|
+
|
|
|
+
|
|
|
+def _is_search_page(ex: SafeExecutor) -> bool:
|
|
|
+ """判断当前是否在搜索页面:只检测屏幕顶部20%区域内是否有「筛选」"""
|
|
|
+ import tempfile
|
|
|
+ w, h = ex.driver.window_size()
|
|
|
+ tmp = str(SCREENSHOT_DIR / "_check_search.png")
|
|
|
+ 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, str(Path(__file__).parent / "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兼容问题)
|
|
|
+ import hashlib
|
|
|
+ _hash = hashlib.md5(shop_name.encode()).hexdigest()[:8] if shop_name else "unknown"
|
|
|
+ _pfx = lambda name: str(SCREENSHOT_DIR / f"_s4_{_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)
|