| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931 |
- """
- 饿了么闪购 — 主入口 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)
|