main.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920
  1. """
  2. 饿了么闪购 — 主入口 V1
  3. 步骤驱动,按步执行
  4. """
  5. import sys
  6. import time
  7. import re
  8. import cv2
  9. import numpy as np
  10. from pathlib import Path
  11. from typing import Optional
  12. sys.path.insert(0, str(Path(__file__).parent / "steps"))
  13. from ocr import OCR
  14. from executor import SafeExecutor
  15. from ai_helper import AIParser
  16. # ── 配置 ────────────────────────────────────────────────
  17. APP_PACKAGE = "me.ele"
  18. SCREENSHOT_DIR = Path(__file__).parent / "screenshots"
  19. OCR = OCR()
  20. def _find_device(device_id: str = "") -> str:
  21. import subprocess
  22. r = subprocess.run(["adb", "devices"], capture_output=True, text=True, timeout=5)
  23. devices = []
  24. for line in r.stdout.strip().split("\n")[1:]:
  25. if line.strip() and "device" in line and "offline" not in line:
  26. s = line.split("\t")[0].strip()
  27. if s:
  28. devices.append(s)
  29. if not devices:
  30. raise RuntimeError("未找到设备")
  31. # 如果指定了设备ID,精确匹配
  32. if device_id:
  33. for d in devices:
  34. if d == device_id:
  35. return d
  36. raise RuntimeError(f"未找到指定设备: {device_id},可用设备: {devices}")
  37. # 只有一台直接返回
  38. if len(devices) == 1:
  39. return devices[0]
  40. # 多台设备:列出并让用户选择
  41. print(f"\n发现 {len(devices)} 台设备:")
  42. for i, d in enumerate(devices):
  43. print(f" [{i}] {d}")
  44. while True:
  45. try:
  46. choice = input(f"请选择设备 [0-{len(devices)-1}],回车默认第一台: ").strip()
  47. if choice == "":
  48. return devices[0]
  49. idx = int(choice)
  50. if 0 <= idx < len(devices):
  51. return devices[idx]
  52. except ValueError:
  53. pass
  54. print(f"输入无效,请输入 0-{len(devices)-1}")
  55. def _find_text_in_area(shot_path: str, target: str, max_y: int) -> Optional[dict]:
  56. results = OCR.recognize(shot_path, detail="all")
  57. for r in results:
  58. if target in r["text"]:
  59. y = r["bbox"][0][1]
  60. if y < max_y:
  61. cx = r["bbox"][0][0] + (r["bbox"][2][0] - r["bbox"][0][0]) // 2
  62. cy = y + (r["bbox"][2][1] - y) // 2
  63. return {"x": cx, "y": cy, "text": r["text"], "conf": r["confidence"]}
  64. return None
  65. def _screenshot(ex: SafeExecutor, name: str) -> str:
  66. import os
  67. SCREENSHOT_DIR.mkdir(exist_ok=True)
  68. path = str(SCREENSHOT_DIR / name)
  69. if os.path.exists(path):
  70. # 保留历史截图副本:固定文件名被 test/调试脚本引用,不能被后续运行覆盖丢失
  71. import shutil
  72. stem, ext = os.path.splitext(name)
  73. backup = str(SCREENSHOT_DIR / f"{stem}_{int(time.time() * 1000)}{ext}")
  74. try:
  75. shutil.copy2(path, backup)
  76. except Exception:
  77. pass
  78. ex.driver.screenshot(path)
  79. return path
  80. def _is_search_page(ex: SafeExecutor) -> bool:
  81. """判断当前是否在搜索页面:只检测屏幕顶部20%区域内是否有「筛选」"""
  82. import tempfile
  83. w, h = ex.driver.window_size()
  84. tmp = str(SCREENSHOT_DIR / "_check_search.png")
  85. ex.driver.screenshot(tmp)
  86. texts = OCR.recognize(tmp, rect=[0, 0, w, int(h * 0.2)], detail="text")
  87. return "筛选" in texts
  88. # ── 步骤 1:打开 App ────────────────────────────────────
  89. def step1_open_app(ex: SafeExecutor) -> bool:
  90. print("=" * 40)
  91. print(" 步骤 1:打开饿了么闪购")
  92. print("=" * 40)
  93. w, h = ex.driver.window_size()
  94. print(f"[step1] 屏幕尺寸: {w}x{h}")
  95. print(f"[step1] 关闭 {APP_PACKAGE}...")
  96. ex.driver.app_stop(APP_PACKAGE)
  97. time.sleep(2)
  98. print(f"[step1] 启动 {APP_PACKAGE}...")
  99. ex.driver.app_start(APP_PACKAGE)
  100. time.sleep(5)
  101. shot = _screenshot(ex, "step1_home.png")
  102. texts = OCR.recognize(shot, rect=[0, int(h * 0.88), w, h], detail="text")
  103. print(f"[step1] 底部识别: {texts}")
  104. for t in texts:
  105. if "我的" in t:
  106. print("[step1] OK - 成功进入 App")
  107. return True
  108. print("[step1] FAIL - 未检测到「我的」")
  109. return False
  110. # ── 步骤 2:搜索商品 ────────────────────────────────────
  111. def step2_search(ex: SafeExecutor, keyword: str) -> bool:
  112. print("\n" + "=" * 40)
  113. print(f" 步骤 2:搜索「{keyword}」")
  114. print("=" * 40)
  115. w, h = ex.driver.window_size()
  116. top_th = int(h * 0.3)
  117. # ── 阶段0:点击「看病买药」 ──
  118. shot0 = _screenshot(ex, "step2_phase0.png")
  119. btn_med = _find_text_in_area(shot0, "看病买药", h)
  120. if not btn_med:
  121. print("[step2] FAIL - 未找到「看病买药」")
  122. return False
  123. print(f"[step2] 找到「看病买药」: ({btn_med['x']}, {btn_med['y']})")
  124. ex.tap(btn_med["x"], btn_med["y"])
  125. time.sleep(3)
  126. # ── 阶段1:点击首页搜索栏 ──
  127. shot = _screenshot(ex, "step2_phase1.png")
  128. btn = _find_text_in_area(shot, "搜索", top_th)
  129. if not btn:
  130. print("[step2] FAIL - 未找到「搜索」")
  131. return False
  132. print(f"[step2] 找到「搜索」: ({btn['x']}, {btn['y']})")
  133. cx = btn["x"] - 120 # 搜索左边约120px
  134. cy = btn["y"]
  135. ex.tap(cx, cy)
  136. time.sleep(3)
  137. shot2 = _screenshot(ex, "step2_phase2.png")
  138. btn2 = _find_text_in_area(shot2, "搜索", top_th)
  139. if not btn2:
  140. print("[step2] FAIL - 进入搜索页后找不到「搜索」")
  141. return False
  142. moved = abs(btn2["x"] - btn["x"]) > 50 or abs(btn2["y"] - btn["y"]) > 50
  143. if not moved:
  144. print("[step2] FAIL - 搜索位置未改变")
  145. return False
  146. print(f"[step2] 搜索页搜索: ({btn2['x']}, {btn2['y']})")
  147. cx2 = btn2["x"] - 180 # 搜索页输入框在搜索左边约180px
  148. cy2 = btn2["y"]
  149. ex.tap(cx2, cy2)
  150. time.sleep(2)
  151. print(f"[step2] 聚焦输入框,等待2s")
  152. print(f"[step2] 输入关键词: {keyword}")
  153. ex.driver.set_input_ime(True)
  154. time.sleep(0.3)
  155. ex.driver.send_keys(keyword)
  156. time.sleep(1)
  157. ex.tap(btn2["x"], btn2["y"])
  158. time.sleep(3)
  159. shot3 = _screenshot(ex, "step2_result.png")
  160. raw3 = OCR.recognize(shot3, detail="all")
  161. all_texts = [r["text"] for r in raw3]
  162. has_filter = "筛选" in all_texts
  163. has_express = "快递" in all_texts
  164. kw_found = any(keyword in t for t in all_texts)
  165. print(f"[step2] 有筛选: {has_filter}, 有快递: {has_express}, 关键词存在: {kw_found}")
  166. # 如果有「快递」则点击它
  167. if has_express:
  168. for r in raw3:
  169. if "快递" in r["text"]:
  170. bx = r["bbox"]
  171. cx = (bx[0][0] + bx[2][0]) // 2
  172. cy = (bx[0][1] + bx[2][1]) // 2
  173. print(f"[step2] 点击「快递」: ({cx}, {cy})")
  174. ex.tap(cx, cy)
  175. time.sleep(3)
  176. break
  177. if has_filter or has_express:
  178. print("[step2] OK - 搜索成功")
  179. return True
  180. print("[step2] FAIL - 搜索未成功")
  181. return False
  182. def _adb_swipe_up(ex: SafeExecutor, distance: int):
  183. """ADB 手指从下往上滑,内容下滑"""
  184. import subprocess
  185. w, h = ex.driver.window_size()
  186. swipe_x = w // 2
  187. seg = 3
  188. seg_px = distance // seg
  189. for i in range(seg):
  190. s = int(h * 0.8) - i * 80
  191. e = s - seg_px
  192. if e < 50:
  193. e = 50
  194. subprocess.run(
  195. ["adb", "-s", ex.device_id, "shell", "input", "swipe",
  196. str(swipe_x), str(s), str(swipe_x), str(e), "400"],
  197. capture_output=True, timeout=10
  198. )
  199. time.sleep(0.35)
  200. time.sleep(1.4)
  201. def _get_named_shops(ex: SafeExecutor, shot_name: str) -> list:
  202. """截图 + OCR + AI → 返回有店铺名的列表"""
  203. shot = _screenshot(ex, shot_name)
  204. raw = OCR.recognize(shot, detail="all")
  205. w, h = ex.driver.window_size()
  206. parser = AIParser()
  207. shops = parser.parse_shops(raw, screen_size=(w, h))
  208. # 只保留有效店铺名+价格:店铺名必须含中文或字母(排除纯数字/标点/空格)
  209. import re as _re
  210. valid = []
  211. for s in shops:
  212. name = (s[0] or "").strip()
  213. price = (s[2] or "").strip()
  214. if name and _re.search(r'[一-鿿＀-￯a-zA-Z]', name) and price:
  215. valid.append(s)
  216. return valid
  217. def _shop_key(shop: list) -> str:
  218. """用店铺名+价格去重(去括号内分店名、去尾部点号)"""
  219. import re
  220. name = shop[0]
  221. price = shop[2] if len(shop) > 2 else ""
  222. name = name.replace("(", "(").replace(")", ")")
  223. name = re.sub(r'(.*', '', name)
  224. name = re.sub(r'[..…]+$', '', name)
  225. return f"{name.strip()}|{price.strip()}"
  226. def _visit_shop(ex: SafeExecutor, shop: list, visited: set) -> dict:
  227. """点击进入店铺 → step4 → 返回完整数据 dict"""
  228. key = _shop_key(shop)
  229. if key in visited:
  230. return None
  231. visited.add(key)
  232. shop_name = shop[0]
  233. product_title = shop[1]
  234. price = shop[2]
  235. click_x, click_y = shop[3]
  236. print(f" → 进入 [{shop_name}] 商品: {product_title[:30]} 价格: {price}")
  237. ex.tap(click_x, click_y)
  238. try:
  239. qr_url = step4_parse_qr(ex, product_title, shop_name)
  240. except Exception as e:
  241. print(f" ⚠ step4异常: {e},跳过此店铺")
  242. qr_url = ""
  243. if qr_url == "__TERMINATE__":
  244. print(f" ⚠ 遇到终止信号,停止遍历")
  245. return {"__terminate__": True}
  246. if qr_url:
  247. print(f" ✅ QR: {qr_url[:80]}")
  248. print(f" 📦 采集完成: {shop_name} | {product_title[:30]} | {price} | {qr_url[:60]}")
  249. else:
  250. print(f" ⚠ 未获取到二维码链接")
  251. print(f" 📦 采集完成(无链接): {shop_name} | {product_title[:30]} | {price}")
  252. # 返回搜索页:最多退3次,每次检测顶部区域是否有「筛选」
  253. for _ in range(3):
  254. ex.driver.press("back")
  255. time.sleep(1.4)
  256. if _is_search_page(ex):
  257. break
  258. return {
  259. "shop": shop_name,
  260. "title": product_title,
  261. "price": price,
  262. "link": qr_url or "",
  263. }
  264. def _handle_captcha(ex: SafeExecutor, ocr_texts: list) -> bool:
  265. """处理验证码, 重试5次, 失败等人工, 返回True=已解决"""
  266. import sys as _sys
  267. _sys.path.insert(0, str(Path(__file__).parent / "yzm"))
  268. for attempt in range(1, 6):
  269. print(f" [验证码] 第{attempt}次尝试...")
  270. nine_kw = any("提交" in t or "没有新图片" in t for t in ocr_texts)
  271. if nine_kw:
  272. from nine_grid import solve as solve_nine
  273. ok = solve_nine(ex.driver)
  274. else:
  275. from tmp_captcha_test2 import solve_slider
  276. ok = solve_slider(ex.driver)
  277. if ok:
  278. print(f" ✅ 验证码已解决")
  279. return True
  280. print(f" ❌ 第{attempt}次失败")
  281. time.sleep(1)
  282. print(f" ⚠ 5次自动处理失败, 请人工处理...")
  283. input(" 处理完成后按回车继续...")
  284. return True
  285. def step4_parse_qr(ex: SafeExecutor, product_title: str, shop_name: str = "") -> str:
  286. """
  287. 1. 等待加载 → OCR → AI找商品标题坐标
  288. 2. 点击商品标题 → 进入商品详情
  289. 3. 找右上角"分享" → 点击 → 二维码弹窗
  290. 4. 截图 → pyzbar 解析二维码
  291. 返回 URL 或空字符串
  292. """
  293. # 安全的文件名前缀(用hash避免中文路径cv2兼容问题)
  294. import hashlib
  295. _hash = hashlib.md5(shop_name.encode()).hexdigest()[:8] if shop_name else "unknown"
  296. _pfx = lambda name: str(SCREENSHOT_DIR / f"_s4_{_hash}_{name}")
  297. time.sleep(6)
  298. # ── 检测页面类型:验证码/风控/正常(unknown/qrcode 重试3次)──
  299. for page_retry in range(3):
  300. shot_check = _pfx("page_check.png")
  301. ex.driver.screenshot(shot_check)
  302. check_raw = OCR.recognize(shot_check, detail="all")
  303. # 方法A: 模板匹配检测验证码
  304. import os as _os
  305. captcha_tpl = str(Path(__file__).parent / "files" / "captcha1.png")
  306. if _os.path.exists(captcha_tpl):
  307. si = cv2.imread(shot_check)
  308. ti = cv2.imread(captcha_tpl)
  309. if si is not None and ti is not None:
  310. gs = cv2.cvtColor(si, cv2.COLOR_BGR2GRAY)
  311. gt = cv2.cvtColor(ti, cv2.COLOR_BGR2GRAY)
  312. h_s, w_s = gs.shape
  313. crop_y1, crop_y2 = int(h_s * 0.25), int(h_s * 0.75)
  314. crop_x1, crop_x2 = 0, 400
  315. gs_crop = gs[crop_y1:crop_y2, crop_x1:crop_x2]
  316. scores = []
  317. for fn, ss, tt in [
  318. ("gray", gs_crop, gt),
  319. ("edge", cv2.Canny(gs_crop,30,100), cv2.Canny(gt,30,100)),
  320. ("hist", cv2.equalizeHist(gs_crop), cv2.equalizeHist(gt)),
  321. ("blur", cv2.GaussianBlur(gs_crop,(3,3),0), cv2.GaussianBlur(gt,(3,3),0)),
  322. ("otsu", cv2.threshold(gs_crop,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)[1],
  323. cv2.threshold(gt,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)[1]),
  324. ]:
  325. if ss.ndim == 2 and tt.ndim == 2 and ss.shape[0] >= tt.shape[0] and ss.shape[1] >= tt.shape[1]:
  326. r = cv2.matchTemplate(ss, tt, cv2.TM_CCOEFF_NORMED)
  327. _, mv, _, _ = cv2.minMaxLoc(r)
  328. scores.append((mv, fn))
  329. if scores:
  330. best_v = max(s[0] for s in scores)
  331. best_m = max(scores, key=lambda s: s[0])[1]
  332. print(f" 验证码模板匹配: {best_m}={best_v:.3f}")
  333. captcha_kw = any("拖动滑块" in r["text"] or "请按住滑块" in r["text"] or "安全验证" in r["text"] for r in check_raw)
  334. nine_kw = any("提交" in r["text"] or "没有新图片" in r["text"] for r in check_raw)
  335. if best_v >= 0.30 and (captcha_kw or nine_kw):
  336. print(f" ⚠ 检测到验证码,尝试自动处理...")
  337. if _handle_captcha(ex, [r["text"] for r in check_raw]):
  338. continue
  339. return "__TERMINATE__"
  340. elif best_v >= 0.30 and not captcha_kw:
  341. print(f" ⚠ 模板匹配命中但OCR无验证码关键词,忽略")
  342. page_type = AIParser().check_page(check_raw)
  343. ptype = page_type.get("type", "unknown")
  344. if ptype == "risk":
  345. print(f" ⚠ AI检测到验证码,尝试自动处理...")
  346. if _handle_captcha(ex, [r["text"] for r in check_raw]):
  347. continue
  348. return "__TERMINATE__"
  349. if ptype == "normal":
  350. break # 正常,跳出重试循环
  351. # qrcode 或 unknown → 可能未加载完成
  352. if page_retry < 2:
  353. print(f" 检测到{ptype}页面,可能未加载完成,第{page_retry+1}次重试...")
  354. time.sleep(2)
  355. else:
  356. # 3次重试后仍异常
  357. import shutil
  358. err_dir = SCREENSHOT_DIR / "unrecognized"
  359. err_dir.mkdir(exist_ok=True)
  360. shutil.copy(shot_check, str(err_dir / f"{ptype}_{int(time.time())}.png"))
  361. print(f" ⚠ 3次检测均为{ptype}页面,终止程序")
  362. return "__TERMINATE__"
  363. # normal → 继续
  364. # ── 店铺页判断:OCR同时检测到「刚刚搜过」和「评价」说明在店铺页 ──
  365. in_shop = False
  366. for _ in range(10):
  367. shop_check = _pfx("shop_check.png")
  368. ex.driver.screenshot(shop_check)
  369. shop_raw = OCR.recognize(shop_check, detail="text")
  370. has_ganggang = any("刚刚搜过" in t for t in shop_raw)
  371. has_pingjia = any("评价" in t for t in shop_raw)
  372. if has_ganggang and has_pingjia:
  373. in_shop = True
  374. print(f" 已确认在店铺页")
  375. break
  376. time.sleep(1)
  377. if not in_shop:
  378. print(f" ⚠ 未检测到店铺页,继续尝试...")
  379. # ── 第1步:截图 + AI找商品标题坐标 ──
  380. shot = _pfx("shop.png")
  381. ex.driver.screenshot(shot)
  382. raw = OCR.recognize(shot, detail="all")
  383. sorted_r = sorted(raw, key=lambda r: r["bbox"][0][1])
  384. lines = []
  385. for r in sorted_r:
  386. cx = (r["bbox"][0][0] + r["bbox"][2][0]) // 2
  387. cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2
  388. lines.append(f"[x={cx:4d}, y={cy:4d}] {r['text']}")
  389. ocr_text = "\n".join(lines)
  390. system_prompt = """你收到店铺页的OCR文字。商品标题文字坐标已知(从OCR中有x,y)。
  391. 请找到和以下商品标题匹配的文字块,返回其点击坐标。
  392. 【重要规则】
  393. - 坐标必须从OCR数据中选取,不得编造或估算
  394. - 如果找不到完全匹配的,找最相似的
  395. - 如果完全找不到,返回null
  396. 只返回JSON:
  397. {"title_xy": [x, y] 或 null, "shop": "店铺名"}"""
  398. parser = AIParser()
  399. resp = parser._call(system_prompt, f"商品标题: {product_title}\n\nOCR文字:\n{ocr_text}\n\n请返回商品标题坐标。")
  400. import json
  401. cleaned = resp.strip()
  402. if cleaned.startswith("```"):
  403. cl = cleaned.split("\n")
  404. if cl[0].startswith("```"): cl = cl[1:]
  405. if cl and cl[-1].strip() == "```": cl = cl[:-1]
  406. cleaned = "\n".join(cl).strip()
  407. try:
  408. data = json.loads(cleaned)
  409. title_xy = data.get("title_xy")
  410. except json.JSONDecodeError:
  411. title_xy = None
  412. if not title_xy or not isinstance(title_xy, list) or len(title_xy) != 2:
  413. print(f" ⚠ AI未返回有效坐标: {title_xy}")
  414. return ""
  415. tx, ty = title_xy
  416. if tx is None or ty is None:
  417. print(f" ⚠ AI返回空坐标")
  418. return ""
  419. tx, ty = int(tx), int(ty)
  420. w, h = ex.driver.window_size()
  421. if not (0 <= tx <= w and 0 <= ty <= h):
  422. print(f" ⚠ 坐标越界: ({tx},{ty}) 超出屏幕 {w}x{h}")
  423. return ""
  424. # ── 第2步:点击商品标题 → 进入商品详情(最多重试3次)──
  425. entered_detail = False
  426. for attempt in range(3):
  427. print(f" 点击商品标题: ({tx},{ty}) (第{attempt+1}次)")
  428. ex.tap(tx, ty)
  429. # 检测是否进入商品详情页
  430. for _ in range(5):
  431. time.sleep(2)
  432. detail_check = _pfx("detail_check.png")
  433. ex.driver.screenshot(detail_check)
  434. detail_raw = OCR.recognize(detail_check, detail="all")
  435. detail_texts = [r["text"] for r in detail_raw]
  436. # 检测商品详情页关键词
  437. if any("加入购物车" in t or "立即购买" in t or "选规格" in t or "商品详情页" in t for t in detail_texts):
  438. print(f" 已进入商品详情页")
  439. entered_detail = True
  440. break
  441. # 检测验证码页面
  442. captcha_kw = any("拖动滑块" in t or "请按住滑块" in t or "安全验证" in t for t in detail_texts)
  443. captcha_tpl = str(Path(__file__).parent / "files" / "captcha1.png")
  444. tpl_match = False
  445. if _os.path.exists(captcha_tpl):
  446. si = cv2.imread(detail_check)
  447. ti = cv2.imread(captcha_tpl)
  448. if si is not None and ti is not None:
  449. gs = cv2.cvtColor(si, cv2.COLOR_BGR2GRAY)
  450. gt = cv2.cvtColor(ti, cv2.COLOR_BGR2GRAY)
  451. h_s, w_s = gs.shape
  452. crop_y1, crop_y2 = int(h_s * 0.25), int(h_s * 0.75)
  453. gs_crop = gs[crop_y1:crop_y2, 0:400]
  454. best_v = 0
  455. for fn, ss, tt in [
  456. ("gray", gs_crop, gt),
  457. ("edge", cv2.Canny(gs_crop,30,100), cv2.Canny(gt,30,100)),
  458. ("hist", cv2.equalizeHist(gs_crop), cv2.equalizeHist(gt)),
  459. ("blur", cv2.GaussianBlur(gs_crop,(3,3),0), cv2.GaussianBlur(gt,(3,3),0)),
  460. ("otsu", cv2.threshold(gs_crop,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)[1],
  461. cv2.threshold(gt,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)[1]),
  462. ]:
  463. if ss.ndim == 2 and tt.ndim == 2 and ss.shape[0] >= tt.shape[0] and ss.shape[1] >= tt.shape[1]:
  464. r = cv2.matchTemplate(ss, tt, cv2.TM_CCOEFF_NORMED)
  465. _, mv, _, _ = cv2.minMaxLoc(r)
  466. best_v = max(best_v, mv)
  467. tpl_match = best_v >= 0.30
  468. if captcha_kw or tpl_match:
  469. print(f" ⚠ 检测到验证码页面,尝试自动处理...")
  470. if _handle_captcha(ex, detail_texts):
  471. continue
  472. return "__TERMINATE__"
  473. # 不在详情页,检测是否还在店铺页
  474. has_ganggang = any("刚刚搜过" in t for t in detail_texts)
  475. has_pingjia = any("评价" in t for t in detail_texts)
  476. if has_ganggang and has_pingjia:
  477. print(f" 仍在店铺页,重试...")
  478. break # 跳出内层循环
  479. if entered_detail:
  480. break
  481. # 第1次失败后,重新OCR+AI获取坐标(可能是页面滚动导致坐标偏移)
  482. if attempt < 2:
  483. print(f" 重新OCR获取坐标...")
  484. re_shot = _pfx("shop.png")
  485. ex.driver.screenshot(re_shot)
  486. re_raw = OCR.recognize(re_shot, detail="all")
  487. re_sorted = sorted(re_raw, key=lambda r: r["bbox"][0][1])
  488. re_lines = []
  489. for r in re_sorted:
  490. r_cx = (r["bbox"][0][0] + r["bbox"][2][0]) // 2
  491. r_cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2
  492. re_lines.append(f"[x={r_cx:4d}, y={r_cy:4d}] {r['text']}")
  493. re_ocr_text = "\n".join(re_lines)
  494. re_resp = parser._call(system_prompt, f"商品标题: {product_title}\n\nOCR文字:\n{re_ocr_text}\n\n请返回商品标题坐标。")
  495. re_cleaned = re_resp.strip()
  496. if re_cleaned.startswith("```"):
  497. rl = re_cleaned.split("\n")
  498. if rl[0].startswith("```"): rl = rl[1:]
  499. if rl and rl[-1].strip() == "```": rl = rl[:-1]
  500. re_cleaned = "\n".join(rl).strip()
  501. try:
  502. re_data = json.loads(re_cleaned)
  503. re_xy = re_data.get("title_xy")
  504. if re_xy and isinstance(re_xy, list) and len(re_xy) == 2 and re_xy[0] is not None:
  505. tx, ty = int(re_xy[0]), int(re_xy[1])
  506. ww, hh = ex.driver.window_size()
  507. if not (0 <= tx <= ww and 0 <= ty <= hh):
  508. print(f" ⚠ 新坐标越界: ({tx},{ty}),保持原坐标")
  509. else:
  510. print(f" 新坐标: ({tx},{ty})")
  511. except Exception:
  512. pass
  513. else:
  514. pass # 3次重试结束
  515. if not entered_detail:
  516. print(f" ⚠ 3次点击未进入商品详情页,跳过")
  517. return ""
  518. # ── 第3步:ORB特征匹配找分享图标 ──
  519. share_shot = _pfx("find_share.png")
  520. ex.driver.screenshot(share_shot)
  521. screen = cv2.imread(share_shot)
  522. template_path = str(Path(__file__).parent / "files" / "share.png")
  523. template = cv2.imread(template_path)
  524. sx, sy = None, None
  525. if screen is not None and template is not None:
  526. h_s, w_s = screen.shape[:2]
  527. # 右上角区域(分享图标永远在右上)
  528. roi_x1, roi_y1 = w_s * 2 // 3, 0
  529. roi = screen[roi_y1:h_s // 4, roi_x1:w_s]
  530. # 方法A: SIFT 特征匹配(限制右上角区域,减少干扰)
  531. sx, sy = None, None
  532. sift = cv2.SIFT_create(nfeatures=1500)
  533. kp1, des1 = sift.detectAndCompute(template, None)
  534. kp2, des2 = sift.detectAndCompute(roi, None)
  535. if des1 is not None and des2 is not None and len(kp1) >= 2 and len(kp2) >= 2:
  536. bf = cv2.BFMatcher()
  537. matches = bf.knnMatch(des1, des2, k=2)
  538. good = []
  539. for m, n in matches:
  540. if m.distance < 0.75 * n.distance:
  541. good.append(m)
  542. print(f" 分享SIFT(右上区域): 模板{len(kp1)}特征 ROI{len(kp2)}特征 优质{len(good)}")
  543. if len(good) >= 4:
  544. src_pts = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
  545. dst_pts = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
  546. matrix, _ = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
  547. if matrix is not None:
  548. h_t, w_t = template.shape[:2]
  549. corners = np.float32([[0, 0], [w_t, 0], [w_t, h_t], [0, h_t]]).reshape(-1, 1, 2)
  550. transformed = cv2.perspectiveTransform(corners, matrix)
  551. sx = roi_x1 + int(np.mean(transformed[:, 0, 0]))
  552. sy = int(np.mean(transformed[:, 0, 1]))
  553. print(f" 分享SIFT匹配: ({sx},{sy})")
  554. # 方法B: 多尺度模板匹配(右上角区域)
  555. if sx is None:
  556. best_val, best_loc, best_sw, best_sh = 0, None, 0, 0
  557. for scale in [0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3]:
  558. scaled = cv2.resize(template, None, fx=scale, fy=scale)
  559. sw, sh = scaled.shape[1], scaled.shape[0]
  560. if sh > roi.shape[0] or sw > roi.shape[1]:
  561. continue
  562. res = cv2.matchTemplate(roi, scaled, cv2.TM_CCOEFF_NORMED)
  563. _, mv, _, ml = cv2.minMaxLoc(res)
  564. if mv > best_val:
  565. best_val, best_loc, best_sw, best_sh = mv, ml, sw, sh
  566. t_edge = cv2.Canny(scaled, 30, 100)
  567. r_edge = cv2.Canny(roi, 30, 100)
  568. if t_edge.shape[0] <= r_edge.shape[0] and t_edge.shape[1] <= r_edge.shape[1]:
  569. res2 = cv2.matchTemplate(r_edge, t_edge, cv2.TM_CCOEFF_NORMED)
  570. _, mv2, _, ml2 = cv2.minMaxLoc(res2)
  571. if mv2 > best_val:
  572. best_val, best_loc, best_sw, best_sh = mv2, ml2, sw, sh
  573. print(f" 分享模板匹配(右上): 最佳={best_val:.3f}")
  574. if best_val >= 0.26 and best_loc is not None:
  575. sx = roi_x1 + best_loc[0] + best_sw // 2
  576. sy = best_loc[1] + best_sh // 2
  577. if sx is not None and sy is not None:
  578. print(f" 分享图标: ({sx},{sy})")
  579. ex.tap(sx, sy)
  580. else:
  581. print(f" ⚠ 未找到分享图标")
  582. return ""
  583. # 等弹窗出现,同时记录"分享到"y坐标用于QR裁剪
  584. share_y = None
  585. waimai_y = None
  586. for _ in range(8):
  587. time.sleep(1)
  588. ck = _pfx("share_popup.png")
  589. ex.driver.screenshot(ck)
  590. detail = OCR.recognize(ck, detail="all")
  591. texts = [r["text"] for r in detail]
  592. if any("分享到" in t for t in texts):
  593. print(f" 分享弹窗出现")
  594. # 记录"分享到"和"外卖"的y坐标
  595. for r in detail:
  596. cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2
  597. if "分享到" in r["text"] and share_y is None:
  598. share_y = cy
  599. if "外卖" in r["text"] and waimai_y is None:
  600. waimai_y = cy
  601. break
  602. if share_y is None:
  603. share_y = int(ex.driver.window_size()[1] * 0.74) # fallback
  604. # ── 第4步:截图 → 多方法解析二维码(多次重试) ──
  605. def _decode_qr(img, share_y):
  606. """基于OCR定位的share_y裁剪QR区域解析"""
  607. if img is None: return ""
  608. h, w = img.shape[:2]
  609. gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
  610. detector = cv2.QRCodeDetector()
  611. # 裁剪区域:y从"外卖"(或估计值)到"分享到", x从60%到92%
  612. y_top = waimai_y if waimai_y else max(0, share_y - 280)
  613. y_bot = share_y
  614. x_l, x_r = int(w * 0.60), int(w * 0.92)
  615. crop_save = img[y_top:y_bot, x_l:x_r]
  616. cv2.imwrite(_pfx("qr_crop.png"), crop_save)
  617. def _try_decode(roi_gray, zooms=(1,)):
  618. """在灰度图上尝试多种方式解码"""
  619. if roi_gray is None or roi_gray.size == 0 or roi_gray.shape[0] == 0 or roi_gray.shape[1] == 0:
  620. return ""
  621. for z in zooms:
  622. if z > 1:
  623. big = cv2.resize(roi_gray, None, fx=z, fy=z, interpolation=cv2.INTER_NEAREST)
  624. else:
  625. big = roi_gray
  626. data, _, _ = detector.detectAndDecode(big)
  627. if data: return data
  628. # OTSU + zoom
  629. for z in zooms:
  630. _, th = cv2.threshold(roi_gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
  631. big = cv2.resize(th, None, fx=z, fy=z, interpolation=cv2.INTER_NEAREST) if z > 1 else th
  632. data, _, _ = detector.detectAndDecode(big)
  633. if data: return data
  634. return ""
  635. # 方法A: 全图detect定位QR → 裁200x200
  636. ok, points = detector.detect(gray)
  637. if ok and points is not None and len(points) > 0:
  638. pts = points[0].astype(int)
  639. cx = int(np.mean(pts[:, 0]))
  640. cy = int(np.mean(pts[:, 1]))
  641. x1, y1 = max(0, cx - 100), max(0, cy - 100)
  642. x2, y2 = min(w, cx + 100), min(h, cy + 100)
  643. if x2 > x1 and y2 > y1:
  644. data = _try_decode(gray[y1:y2, x1:x2], (1, 2, 3))
  645. if data: return data
  646. # 方法B: 200x200滑动窗口扫描(基于OCR定位区域)
  647. scan_area = gray[y_top:y_bot, x_l:x_r]
  648. sh, sw = scan_area.shape
  649. step = min(80, max(40, sh // 3, sw // 3))
  650. for y in range(0, max(1, sh - 200), step):
  651. for x in range(0, max(1, sw - 200), step):
  652. patch = scan_area[y:y+200, x:x+200]
  653. data = _try_decode(patch, (1, 2))
  654. if data: return data
  655. # 方法C: 固定区域 fallback(基于OCR定位)
  656. crop = gray[y_top:y_bot, x_l:x_r]
  657. data = _try_decode(crop, (1, 2, 3, 4))
  658. if data: return data
  659. # 方法D: 全图兜底(裁剪失败时直接在全图上尝试)
  660. data = _try_decode(gray, (1, 2, 3))
  661. if data: return data
  662. # 方法E: 全图 OTSU + 放大
  663. _, full_th = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
  664. for z in (1, 2, 3):
  665. big = cv2.resize(full_th, None, fx=z, fy=z, interpolation=cv2.INTER_CUBIC) if z > 1 else full_th
  666. data, _, _ = detector.detectAndDecode(big)
  667. if data: return data
  668. return ""
  669. for retry in range(6): # 最多等 5+2*5=15秒
  670. time.sleep(5 if retry == 0 else 2)
  671. qr_shot = _pfx("qr.png")
  672. ex.driver.screenshot(qr_shot)
  673. data = _decode_qr(cv2.imread(qr_shot), share_y)
  674. if data:
  675. print(f" QR链接: {data[:100]}")
  676. return data
  677. return ""
  678. # ── 步骤 3:滑动 + 逐个点击店铺 ──────────────────────
  679. def step3_swipe_and_enter(ex: SafeExecutor, keyword: str) -> list:
  680. """
  681. 截图 → AI分析 → 逐个点击全部可见店铺 → 下滑加载更多 → 继续点击 → 直到全部遍历
  682. """
  683. print("\n" + "=" * 40)
  684. print(" 步骤 3:遍历店铺")
  685. print("=" * 40)
  686. visited = set()
  687. w, h = ex.driver.window_size()
  688. batch_no = 0
  689. empty_streak = 0 # 连续没有新店铺的批次数
  690. all_results = []
  691. while True:
  692. named = _get_named_shops(ex, f"step3_b{batch_no}.png")
  693. new_ones = [s for s in named if _shop_key(s) not in visited]
  694. print(f"[step3] 批次{batch_no}: 共{len(named)}个, 新{len(new_ones)}个")
  695. if not new_ones:
  696. empty_streak += 1
  697. print(f"[step3] 无新店铺 (连续{empty_streak}/3)")
  698. if empty_streak >= 3:
  699. print(f"[step3] 连续3批无新店铺,结束")
  700. break
  701. # 滑动后再试
  702. print(f"[step3] 滑动查看下一批")
  703. if len(named) >= 2:
  704. target_y = named[-2][4]
  705. swipe_dist = target_y - int(h * 0.15)
  706. if swipe_dist > 0:
  707. _adb_swipe_up(ex, swipe_dist)
  708. else:
  709. _adb_swipe_up(ex, int(h * 0.15))
  710. else:
  711. _adb_swipe_up(ex, int(h * 0.3))
  712. time.sleep(2)
  713. batch_no += 1
  714. continue
  715. empty_streak = 0 # 有新店铺,重置计数
  716. for shop in new_ones:
  717. result = _visit_shop(ex, shop, visited)
  718. if result and result.get("__terminate__"):
  719. print("[step3] 收到终止信号,停止遍历")
  720. all_results = [r for r in all_results if not r.get("__terminate__")]
  721. break
  722. if result:
  723. all_results.append(result)
  724. else:
  725. # for 正常结束 → 滑动到倒数第二个卡片的配送距离位置
  726. print(f"[step3] 已访问 {len(visited)} 个,滑动查看下一批")
  727. if len(named) >= 2:
  728. target_y = named[-2][4] # 倒数第二个卡片的配送距离y坐标
  729. swipe_dist = target_y - int(h * 0.15)
  730. if swipe_dist > 0:
  731. _adb_swipe_up(ex, swipe_dist)
  732. else:
  733. _adb_swipe_up(ex, int(h * 0.15))
  734. else:
  735. _adb_swipe_up(ex, int(h * 0.3))
  736. time.sleep(2)
  737. batch_no += 1
  738. continue
  739. # break 出来 → 结束
  740. break
  741. # ── 输出最终结果表 ──
  742. print("\n" + "=" * 70)
  743. print(f" 最终结果 ({len(all_results)} 个店铺)")
  744. print("=" * 70)
  745. for i, r in enumerate(all_results, 1):
  746. link_short = r["link"][:55] + "..." if len(r["link"]) > 55 else r["link"]
  747. print(f" [{i}] {r['shop']}")
  748. print(f" 商品: {r['title'][:40]}")
  749. print(f" 价格: {r['price']}")
  750. print(f" 链接: {link_short}")
  751. print()
  752. return all_results
  753. # ── 步骤 4:(保留,当前为空 ──────────────────────────
  754. def step4_empty(ex: SafeExecutor):
  755. """占位,供后续扩展"""
  756. pass
  757. # ── 主入口 ──────────────────────────────────────────────
  758. if __name__ == "__main__":
  759. args = sys.argv[1:]
  760. device_id = "T4VK4LM7AAUOV8AY"
  761. # 解析 --device 参数
  762. filtered = []
  763. i = 0
  764. while i < len(args):
  765. if args[i] == "--device" and i + 1 < len(args):
  766. device_id = args[i + 1]
  767. i += 2
  768. else:
  769. filtered.append(args[i])
  770. i += 1
  771. cmd = filtered[0] if filtered else "all"
  772. keyword = filtered[1] if len(filtered) > 1 else "矿泉水"
  773. print("设备连接中...")
  774. device_id = _find_device(device_id)
  775. print(f"设备: {device_id}")
  776. ex = SafeExecutor(device_id)
  777. if cmd in ("all", "step1"):
  778. ok = step1_open_app(ex)
  779. if not ok:
  780. sys.exit(1)
  781. if cmd in ("all", "step2"):
  782. ok = step2_search(ex, keyword)
  783. if not ok:
  784. sys.exit(1)
  785. if cmd in ("all", "step3"):
  786. visited = step3_swipe_and_enter(ex, keyword)
  787. print(f"\n最终访问: {visited}")
  788. sys.exit(0)
  789. if cmd in ("all", "step4"):
  790. # step4 需要先跑完 step3 获取所有商品标题,单独跑时需要手动传标题
  791. title = keyword
  792. link = step4_parse_qr(ex, title)
  793. print(f"\n链接: {link}")
  794. sys.exit(0)
  795. sys.exit(0)