main.py 36 KB

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