mian2.py 113 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594
  1. """
  2. 饿了么闪购 — 主入口 V1
  3. 步骤驱动,按步执行
  4. """
  5. import sys
  6. import time
  7. import re
  8. import json
  9. import cv2
  10. import numpy as np
  11. from pathlib import Path
  12. from typing import Optional
  13. sys.path.insert(0, str(Path(__file__).parent / "steps"))
  14. from ocr import OCR
  15. from executor import SafeExecutor
  16. from ai_helper1 import AIParser, parse_instructions, extract_value_after, detect_popup
  17. from ai_helper_vision import VisionParser
  18. from db import save_record, get_existing_license
  19. from snapshot import collect_snapshot
  20. # ── 配置 ────────────────────────────────────────────────
  21. APP_PACKAGE = "me.ele"
  22. SCREENSHOT_DIR = Path(__file__).parent / "screenshots"
  23. # 生僻字标题修正开关:
  24. # True = 用云端文本修正标题中的生僻字漏字(如"理王"→"理洫王")
  25. # False = 标题保持OCR原样(默认,更安全——避免形近字误修正,如"温胃舒"被改成"养胃舒"导致错误采集)
  26. ENABLE_RARE_CHAR_CORRECT = True
  27. # 标题过滤开关:
  28. # True = 按品牌/药品名/规格过滤,只采目标商品(正常模式)
  29. # False = 不过滤,直接采集OCR识别的全部结果入库(测试OCR准确率用)
  30. ENABLE_TITLE_FILTER = False
  31. # 视觉识别开关:
  32. # True = 截图直接送视觉大模型识别(方案A,生僻字更准,坐标精度依赖模型)
  33. # False = 本地OCR + 文本AI解析(原有方案)
  34. USE_VISION_PARSER = True
  35. # 验证码频率控制:
  36. # 一天内 ≥CAPTCHA_DAILY_LIMIT 次 → 立即停止采集并回告(风控可能封号)
  37. # 每累计 CAPTCHA_REST_EVERY 次 → 休息 CAPTCHA_REST_MINUTES 分钟(第2/4/6次休息,第8次直接停止)
  38. CAPTCHA_DAILY_LIMIT = 8
  39. CAPTCHA_REST_EVERY = 2
  40. CAPTCHA_REST_MINUTES = 30
  41. CAPTCHA_LOG_DIR = Path(__file__).parent / "logs" # 验证码日志目录(每设备独立一个文件,独立计数)
  42. CAPTCHA_ABORTED = False # 全局标志:验证码频繁触发停止采集(调度上报用)
  43. CAPTCHA_ABORT_REASON = "" # 停止原因(调度回告用)
  44. ACCOUNT_ABORTED = False # 全局标志:账号被踢/封号,停止采集(调度上报用)
  45. CURRENT_PAGE = 0 # 当前页码(逐页回告/终止回告用,调度侧存续采集进度)
  46. # 验证码判定截图存档目录(每次判定出现验证码时截图保存,人工确认是否误判)
  47. CAPTCHA_CHECK_DIR = Path(__file__).parent / "logs" / "captcha_check"
  48. # 休息期间进度回告用的全局上下文(step3 任务开始时设置,_captcha_rest 休息时每10分钟回告)
  49. _SCHEDULER = None
  50. _TASK_ID = None
  51. _CRAWLED_COUNT = 0
  52. OCR = OCR()
  53. # 说明书打叉坐标缓存(仅本任务内有效):任务中第一个商品模板匹配找到打叉后,
  54. # 本任务后续商品直接复用该坐标(说明书页布局固定,同设备位置不变);
  55. # 新任务开始时清空,重新匹配。
  56. _CLOSE_BTN_CACHE = None
  57. def _find_device(device_id: str = "") -> str:
  58. import subprocess
  59. r = subprocess.run(["adb", "devices"], capture_output=True, text=True, timeout=5)
  60. devices = []
  61. for line in r.stdout.strip().split("\n")[1:]:
  62. if line.strip() and "device" in line and "offline" not in line:
  63. s = line.split("\t")[0].strip()
  64. if s:
  65. devices.append(s)
  66. if not devices:
  67. raise RuntimeError("未找到设备")
  68. # 如果指定了设备ID,精确匹配
  69. if device_id:
  70. for d in devices:
  71. if d == device_id:
  72. return d
  73. raise RuntimeError(f"未找到指定设备: {device_id},可用设备: {devices}")
  74. # 只有一台直接返回
  75. if len(devices) == 1:
  76. return devices[0]
  77. # 多台设备:列出并让用户选择
  78. print(f"\n发现 {len(devices)} 台设备:")
  79. for i, d in enumerate(devices):
  80. print(f" [{i}] {d}")
  81. while True:
  82. try:
  83. choice = input(f"请选择设备 [0-{len(devices)-1}],回车默认第一台: ").strip()
  84. if choice == "":
  85. return devices[0]
  86. idx = int(choice)
  87. if 0 <= idx < len(devices):
  88. return devices[idx]
  89. except ValueError:
  90. pass
  91. print(f"输入无效,请输入 0-{len(devices)-1}")
  92. def _find_text_in_area(shot_path: str, target: str, max_y: int) -> Optional[dict]:
  93. results = OCR.recognize(shot_path, detail="all")
  94. for r in results:
  95. if target in r["text"]:
  96. y = r["bbox"][0][1]
  97. if y < max_y:
  98. cx = r["bbox"][0][0] + (r["bbox"][2][0] - r["bbox"][0][0]) // 2
  99. cy = y + (r["bbox"][2][1] - y) // 2
  100. return {"x": cx, "y": cy, "text": r["text"], "conf": r["confidence"]}
  101. return None
  102. def _shot_path(ex: SafeExecutor, name: str) -> str:
  103. """截图路径:按 设备ID/步骤 分类组织目录(screenshots/设备/step1/xxx.png)"""
  104. cat = "misc"
  105. for prefix, c in (("step1", "step1"), ("step2", "step2"), ("step3", "step3"),
  106. ("_s4_", "step4"), ("sort", "step2"), ("inst_", "instructions"),
  107. ("lic_", "license"), ("snap_", "snapshot"), ("ad_", "popup")):
  108. if name.startswith(prefix):
  109. cat = c
  110. break
  111. d = SCREENSHOT_DIR / ex.device_id / cat
  112. d.mkdir(parents=True, exist_ok=True)
  113. return str(d / name)
  114. def _screenshot(ex: SafeExecutor, name: str) -> str:
  115. import os
  116. # 按 设备ID/步骤 隔离截图(目录已含设备ID,文件名不再加设备前缀)
  117. path = _shot_path(ex, name)
  118. if os.path.exists(path):
  119. # 保留历史截图副本:固定文件名被 test/调试脚本引用,不能被后续运行覆盖丢失
  120. import shutil
  121. stem, ext = os.path.splitext(name)
  122. backup = str(Path(path).parent / f"{stem}_{int(time.time() * 1000)}{ext}")
  123. try:
  124. shutil.copy2(path, backup)
  125. except Exception:
  126. pass
  127. # 自动清理:每个固定文件只保留最近3份备份,超出删除最旧的
  128. try:
  129. olds = sorted(
  130. Path(path).parent.glob(f"{stem}_[0-9]*{ext}"),
  131. key=lambda p: p.stat().st_mtime, reverse=True,
  132. )
  133. for p in olds[3:]:
  134. p.unlink(missing_ok=True)
  135. except Exception:
  136. pass
  137. # 方案2:截图后验证完整性(adb 流式传输可能中断,导致 PNG 损坏)
  138. for _try in range(3):
  139. ex.driver.screenshot(path)
  140. if cv2.imread(path) is not None:
  141. break
  142. time.sleep(0.5)
  143. return path
  144. def _where_am_i(ex: SafeExecutor) -> str:
  145. """判断当前位置:list=搜索结果列表页 home=首页 other=其他(全屏OCR,区分列表页与首页防止退过头)"""
  146. import os as _os
  147. tmp = _shot_path(ex, "check_pos.png")
  148. ex.driver.screenshot(tmp)
  149. if cv2.imread(tmp) is None:
  150. ex.driver.screenshot(tmp)
  151. texts = [r["text"] for r in OCR.recognize(tmp, detail="all")]
  152. if any("筛选" in t for t in texts):
  153. return "list"
  154. if any("看病买药" in t for t in texts) and any("我的" in t for t in texts):
  155. return "home"
  156. return "other"
  157. def _is_search_page(ex: SafeExecutor) -> bool:
  158. """判断当前是否在搜索页面:只检测屏幕顶部20%区域内是否有「筛选」"""
  159. import tempfile, os
  160. w, h = ex.driver.window_size()
  161. tmp = _shot_path(ex, "check_search.png")
  162. ex.driver.screenshot(tmp)
  163. if cv2.imread(tmp) is None:
  164. ex.driver.screenshot(tmp)
  165. texts = OCR.recognize(tmp, rect=[0, 0, w, int(h * 0.2)], detail="text")
  166. return "筛选" in texts
  167. # ── 步骤 1:打开 App ────────────────────────────────────
  168. def step1_open_app(ex: SafeExecutor) -> bool:
  169. print("=" * 40)
  170. print(" 步骤 1:打开饿了么闪购")
  171. print("=" * 40)
  172. w, h = ex.driver.window_size()
  173. print(f"[step1] 屏幕尺寸: {w}x{h}")
  174. print(f"[step1] 关闭 {APP_PACKAGE}...")
  175. ex.driver.app_stop(APP_PACKAGE)
  176. time.sleep(2)
  177. print(f"[step1] 启动 {APP_PACKAGE}...")
  178. ex.driver.app_start(APP_PACKAGE)
  179. time.sleep(5)
  180. # 等待首页加载:底部导航「我的」出现(元素没加载完就多等重试,不一次定生死)
  181. for attempt in range(3):
  182. shot = _screenshot(ex, "step1_home.png")
  183. texts = OCR.recognize(shot, rect=[0, int(h * 0.88), w, h], detail="text")
  184. print(f"[step1] 底部识别(第{attempt+1}次): {texts}")
  185. if any("我的" in t for t in texts):
  186. print("[step1] OK - 成功进入 App")
  187. _close_ad_popup(ex) # 每天首启的广告弹窗
  188. return True
  189. time.sleep(3)
  190. print("[step1] FAIL - 多次重试未检测到「我的」")
  191. # 识别不到"我的"可能因为账号被踢/封号——检测登录页xpath确认(存在才是封号)
  192. _check_account_kicked(ex)
  193. return False
  194. # ── 步骤 2:搜索商品 ────────────────────────────────────
  195. def _click_low_price_sort(ex: SafeExecutor) -> bool:
  196. """
  197. 搜索结果排序:点「综合」→ 选「低价优先」(失败不阻塞,找不到就跳过)。
  198. 找不到「低价优先」时再点一次「综合」重试,仍找不到则关闭排序弹窗。
  199. """
  200. try:
  201. # 1. 找「综合」并点击(排序入口,结果页顶部)
  202. shot = _shot_path(ex, "sort1.png")
  203. ex.driver.screenshot(shot)
  204. btn = None
  205. for r in OCR.recognize(shot, detail="all"):
  206. if "综合" in r["text"]:
  207. box = r["bbox"]
  208. btn = {"x": (box[0][0] + box[2][0]) // 2, "y": (box[0][1] + box[2][1]) // 2}
  209. break
  210. if not btn:
  211. print(" ⚠ 未找到「综合」排序入口,跳过排序")
  212. return False
  213. print(f" 排序: 点击「综合」({btn['x']}, {btn['y']})")
  214. ex.tap(btn["x"], btn["y"])
  215. time.sleep(2)
  216. # 2. 找「低价优先」
  217. shot2 = _shot_path(ex, "sort2.png")
  218. ex.driver.screenshot(shot2)
  219. low = None
  220. for r in OCR.recognize(shot2, detail="all"):
  221. if "低价优先" in r["text"]:
  222. box = r["bbox"]
  223. low = {"x": (box[0][0] + box[2][0]) // 2, "y": (box[0][1] + box[2][1]) // 2}
  224. break
  225. if low:
  226. print(f" 排序: 点击「低价优先」({low['x']}, {low['y']})")
  227. ex.tap(low["x"], low["y"])
  228. time.sleep(3)
  229. return True
  230. # 3. 没找到:再点一次「综合」关闭排序弹窗(点击综合弹出面板,再点一次收起)
  231. print(" ⚠ 未找到「低价优先」,再点一次「综合」关闭排序弹窗")
  232. ex.tap(btn["x"], btn["y"])
  233. time.sleep(1.5)
  234. return False
  235. except Exception as e:
  236. print(f" ⚠ 排序异常: {e}")
  237. return False
  238. def step2_search(ex: SafeExecutor, keyword: str) -> bool:
  239. print("\n" + "=" * 40)
  240. print(f" 步骤 2:搜索「{keyword}」")
  241. print("=" * 40)
  242. w, h = ex.driver.window_size()
  243. top_th = int(h * 0.3)
  244. # ── 阶段0+1:点击「看病买药」→ 找「搜索」(失败重试3次,3次失败重启App重试,最多2轮)──
  245. btn = None
  246. for round_idx in range(2):
  247. if round_idx > 0:
  248. # 第2轮:重启 App 重新进入(弹窗/页面异常时清状态)
  249. print("[step2] 重启App重新进入...")
  250. if not step1_open_app(ex):
  251. print("[step2] FAIL - 重启App失败")
  252. return False
  253. # 阶段0:点击「看病买药」
  254. shot0 = _screenshot(ex, "step2_phase0.png")
  255. btn_med = _find_text_in_area(shot0, "看病买药", h)
  256. if not btn_med:
  257. print(f"[step2] 未找到「看病买药」(第{round_idx+1}轮)")
  258. continue
  259. print(f"[step2] 找到「看病买药」: ({btn_med['x']}, {btn_med['y']})")
  260. ex.tap(btn_med["x"], btn_med["y"])
  261. time.sleep(5)
  262. _close_ad_popup(ex) # 点击买药后可能出现的广告弹窗
  263. # 阶段1:找「搜索」(重试3次,每次关弹窗+等待)
  264. for attempt in range(3):
  265. shot = _screenshot(ex, "step2_phase1.png")
  266. btn = _find_text_in_area(shot, "搜索", top_th)
  267. if btn:
  268. break
  269. print(f"[step2] 未找到「搜索」(第{attempt+1}次),关闭弹窗后重试")
  270. _close_ad_popup(ex)
  271. time.sleep(2)
  272. if btn:
  273. break # 找到搜索,继续
  274. print("[step2] 3次未找到「搜索」,准备重启App重试")
  275. if not btn:
  276. print("[step2] FAIL - 多次重试+重启后仍未找到「搜索」")
  277. return False
  278. print(f"[step2] 找到「搜索」: ({btn['x']}, {btn['y']})")
  279. cx = btn["x"] - 300 # 搜索左边约120px
  280. cy = btn["y"]
  281. print(f"[step2] 点击搜索栏: ({cx}, {cy})")
  282. ex.tap(cx, cy)
  283. time.sleep(5)
  284. # 搜索页确认:「搜索」位置变化才认为进入(加载慢/弹窗遮挡/点击未生效就重试)
  285. moved = False
  286. btn2 = None
  287. for attempt in range(3):
  288. shot2 = _screenshot(ex, "step2_phase2.png")
  289. btn2 = _find_text_in_area(shot2, "搜索", top_th)
  290. if btn2 and (abs(btn2["x"] - btn["x"]) > 50 or abs(btn2["y"] - btn["y"]) > 50):
  291. moved = True
  292. print(f"[step2] 搜索页搜索: ({btn2['x']}, {btn2['y']})")
  293. break
  294. # 位置没变:先关广告弹窗 + AI验证码检测,再重新点击搜索栏(可能点击没生效或被验证码拦截)
  295. if _ai_check_captcha(ex):
  296. print(f"[step2] AI检测到验证码(第{attempt+1}次),处理完成")
  297. if _close_ad_popup(ex):
  298. print(f"[step2] 已关闭广告弹窗(第{attempt+1}次)")
  299. print(f"[step2] 搜索位置未变化(第{attempt+1}次),重新点击搜索栏")
  300. ex.tap(cx, cy)
  301. time.sleep(4)
  302. if not btn2:
  303. print("[step2] FAIL - 进入搜索页后找不到「搜索」")
  304. return False
  305. if not moved:
  306. print("[step2] FAIL - 搜索位置未改变")
  307. return False
  308. cx2 = btn2["x"] - 180 # 搜索页输入框在搜索左边约180px
  309. cy2 = btn2["y"]
  310. ex.tap(cx2, cy2)
  311. time.sleep(2)
  312. print(f"[step2] 聚焦输入框,等待2s")
  313. print(f"[step2] 输入关键词: {keyword}")
  314. ex.driver.set_input_ime(True)
  315. time.sleep(0.3)
  316. ex.driver.send_keys(keyword)
  317. time.sleep(1)
  318. ex.tap(btn2["x"], btn2["y"])
  319. time.sleep(3)
  320. # 搜索后检测列表页:先处理验证码(可能挡住列表页),再检测列表页特征(加载慢就重试)
  321. for attempt in range(3):
  322. shot3 = _screenshot(ex, "step2_result.png")
  323. raw3 = OCR.recognize(shot3, detail="all")
  324. all_texts = [r["text"] for r in raw3]
  325. # 1. 验证码优先:验证码弹窗会挡住列表页特征,先处理再重新检测
  326. # 关键词没命中时用AI再判断一次(九宫格/点击式验证码文字不在关键词里)
  327. if any(("拖动滑块" in t) or ("请按住滑块" in t) or ("安全验证" in t) for t in all_texts):
  328. print(f"[step2] 检测到列表页验证码(第{attempt+1}次),尝试处理...")
  329. if _handle_captcha(ex, all_texts):
  330. print("[step2] 验证码已解决")
  331. else:
  332. print("[step2] 验证码处理失败")
  333. return False
  334. # 验证码通过后可能出现「出错了/检修中」页,点重新加载
  335. if _click_reload(ex):
  336. print("[step2] 已点击重新加载")
  337. time.sleep(2)
  338. continue
  339. if _ai_check_captcha(ex):
  340. print(f"[step2] AI检测到验证码(第{attempt+1}次),处理完成")
  341. continue
  342. # 1.2 广告弹窗(进入列表页时也可能弹出,挡住列表页特征)
  343. if _close_ad_popup(ex):
  344. print(f"[step2] 已关闭列表页广告弹窗(第{attempt+1}次)")
  345. continue
  346. # 1.5 出错/检修页(无验证码时也可能出现):点「重新加载」后重新检测
  347. if any("重新加载" in t for t in all_texts):
  348. print(f"[step2] 检测到「出错了/检修中」页面,点击重新加载")
  349. _click_reload(ex)
  350. continue
  351. # 2. 列表页特征检测
  352. has_filter = "筛选" in all_texts
  353. has_express = "快递" in all_texts
  354. kw_found = any(keyword in t for t in all_texts)
  355. print(f"[step2] 有筛选: {has_filter}, 有快递: {has_express}, 关键词存在: {kw_found}")
  356. if has_filter or has_express:
  357. # 如果有「快递」则点击它
  358. if has_express:
  359. for r in raw3:
  360. if "快递" in r["text"]:
  361. bx = r["bbox"]
  362. cx = (bx[0][0] + bx[2][0]) // 2
  363. cy = (bx[0][1] + bx[2][1]) // 2
  364. print(f"[step2] 点击「快递」: ({cx}, {cy})")
  365. ex.tap(cx, cy)
  366. time.sleep(3)
  367. break
  368. # 点击「快递」后设置排序:综合 → 低价优先
  369. # _click_low_price_sort(ex)
  370. print("[step2] OK - 搜索成功")
  371. return True
  372. print(f"[step2] 列表页特征未出现(第{attempt+1}次),等3秒重试")
  373. time.sleep(3)
  374. print("[step2] FAIL - 搜索未成功")
  375. return False
  376. def normalize_match_text(value):
  377. """归一化:统一全角/半角、去除空白和零宽字符(美团同款,避免'看起来一样但匹配失败')"""
  378. import unicodedata
  379. text = "" if value is None else str(value)
  380. text = unicodedata.normalize("NFKC", text)
  381. text = re.sub(r"[\s ​-‍]+", "", text)
  382. return text
  383. def _edit_distance(a: str, b: str) -> int:
  384. """编辑距离(Levenshtein),用于OCR错字容错"""
  385. if len(a) < len(b):
  386. a, b = b, a
  387. if not b:
  388. return len(a)
  389. prev = list(range(len(b) + 1))
  390. for i, ca in enumerate(a, 1):
  391. cur = [i]
  392. for j, cb in enumerate(b, 1):
  393. cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
  394. prev = cur
  395. return prev[-1]
  396. def _match_fuzzy(target: str, text: str) -> bool:
  397. """
  398. 容错匹配:目标词是否在文本中(处理OCR漏字/错字1个)。
  399. 用于判断「疑似生僻字」——本地识别差1个字时触发云端验证。
  400. """
  401. if not target:
  402. return True
  403. if target in text:
  404. return True
  405. if len(target) >= 3:
  406. for i in range(len(target)): # 漏字:目标删任意1字后匹配
  407. if target[:i] + target[i + 1:] in text:
  408. return True
  409. for L in (len(target), len(target) + 1, len(target) - 1): # 错字:编辑距离<=1
  410. if L < 2:
  411. continue
  412. for i in range(len(text) - L + 1):
  413. if _edit_distance(target, text[i:i + L]) <= 1:
  414. return True
  415. return False
  416. def _correct_title_with_cloud(title: str, n_brand: str, n_key: str, cloud_texts: list) -> str:
  417. """
  418. 用云端文本修正标题中的生僻字错误(精确子串替换,不做整标题替换):
  419. 标题里 fuzzy 匹配到目标词的子串 → 替换为云端文本确认的准确形式。
  420. 只处理"漏字"场景(云端形式更长,如"理王"→"理洫王");
  421. 同长度的形近替换(如"温胃舒"→"养胃舒")【不修正】——
  422. 云端文本里的目标词可能来自其他商品,误用来改标题会造成错误采集。
  423. """
  424. c_joined = "".join(cloud_texts)
  425. nt = normalize_match_text(title)
  426. for target in (n_brand, n_key):
  427. if not target:
  428. continue
  429. if target in nt:
  430. continue # 已精确匹配,无需修正
  431. if target not in c_joined:
  432. continue # 云端也没有准确形式 → 无法修正,跳过
  433. # 在标题里找漏字子串并替换为准确形式。
  434. # 只处理"目标词比子串长且编辑距离≤1"(漏字:理王→理洫王)——
  435. # 同长度形近替换(温胃舒→养胃舒)不触发:云端文本里的目标词可能来自其他商品,
  436. # 误用来改标题会造成错误采集。
  437. for i in range(len(nt)):
  438. for L in (len(target) - 1, len(target), len(target) + 1):
  439. if L < 2 or i + L > len(nt):
  440. continue
  441. sub = nt[i:i + L]
  442. if len(target) > len(sub) and _edit_distance(target, sub) <= 1:
  443. nt = nt[:i] + target + nt[i + L:]
  444. break
  445. else:
  446. continue
  447. break
  448. return nt
  449. def _is_cjk(ch: str) -> bool:
  450. """是否中文字符"""
  451. return '一' <= ch <= '鿿'
  452. def _is_missing_char(target: str, title_text: str) -> bool:
  453. """
  454. 判断目标词是否在标题里以"漏1字"形式出现(生僻字特征):
  455. 目标词删掉1个字后的子串,在标题里是【独立词】(前后不是汉字)。
  456. 例:理洫王删"洫"="理王",标题"[理王]"里独立 → 漏字 ✓
  457. 例:养胃舒删"舒"="养胃",标题"滋阴养胃"里嵌在词中(前有"滋")→ 非独立 → 不算漏字 ✗
  458. """
  459. for k in range(len(target)):
  460. sub = target[:k] + target[k + 1:]
  461. if len(sub) < 2:
  462. continue
  463. idx = title_text.find(sub)
  464. while idx != -1:
  465. before_ok = idx == 0 or not _is_cjk(title_text[idx - 1])
  466. after_ok = idx + len(sub) >= len(title_text) or not _is_cjk(title_text[idx + len(sub)])
  467. if before_ok and after_ok:
  468. return True
  469. idx = title_text.find(sub, idx + 1)
  470. return False
  471. def _ensure_cloud(cloud_cache: dict, shot_path: str) -> None:
  472. """触发一次云端OCR(每批只调一次),保存 (文本, y) 带坐标的块列表"""
  473. if cloud_cache["done"]:
  474. return
  475. cloud_cache["done"] = True
  476. try:
  477. raw_c = OCR.recognize(shot_path, detail="all", engine="cloud")
  478. if not raw_c:
  479. # 云端返回空(配额用尽/限流时百度返回错误码但不抛异常)→ 视为云端不可用
  480. print("[step3] 云端返回空结果(可能配额不足/限流),按容错处理")
  481. cloud_cache["blocks"] = None
  482. cloud_cache["texts"] = None
  483. return
  484. cloud_cache["blocks"] = [
  485. (normalize_match_text(r["text"]), (r["bbox"][0][1] + r["bbox"][2][1]) // 2)
  486. for r in raw_c
  487. ]
  488. cloud_cache["texts"] = [t for t, _ in cloud_cache["blocks"]]
  489. print(f"[step3] 云端二次确认({len(cloud_cache['blocks'])}块)")
  490. except Exception as e:
  491. print(f"[step3] 云端识别失败({e})")
  492. cloud_cache["blocks"] = None
  493. cloud_cache["texts"] = None
  494. def _cloud_region_text(cloud_cache: dict, card_y: int, max_dy: int = 300) -> Optional[str]:
  495. """
  496. 取卡片 y±max_dy 内的所有云端块合并文本(标题块可能被拆成多块,只取最近一块会漏)。
  497. 阈值300px:排除相邻卡片的目标词(温胃舒 y=867 vs 养胃舒 y=1305 差438 > 300 ✓)
  498. """
  499. blocks = cloud_cache.get("blocks")
  500. if not blocks:
  501. return None
  502. parts = [t for t, cy in blocks if abs(cy - card_y) <= max_dy]
  503. return "".join(parts) if parts else None
  504. def _cloud_crop_confirm(shot_path: str, card_y: int) -> Optional[str]:
  505. """
  506. 用本地OCR的标题块坐标裁剪标题区域(标题y-20 ~ y+80,右列),放大2倍后云端识别。
  507. 裁剪区只含当前商品的标题 → 云端不需要返回坐标,标准版(无配额问题)即可用,且字放大识别更准。
  508. """
  509. try:
  510. img = cv2.imread(shot_path)
  511. if img is None:
  512. return None
  513. h, w = img.shape[:2]
  514. y1, y2 = max(0, card_y - 20), min(h, card_y + 80)
  515. x1 = int(w * 0.15)
  516. crop = img[y1:y2, x1:w]
  517. if crop.size == 0:
  518. return None
  519. big = cv2.resize(crop, None, fx=2, fy=2, interpolation=cv2.INTER_LANCZOS4)
  520. raw = OCR.recognize(big, detail="all", engine="cloud")
  521. texts = [normalize_match_text(r["text"]) for r in raw
  522. if any('一' <= c <= '鿿' for c in r["text"])]
  523. return "".join(texts) if texts else None
  524. except Exception as e:
  525. print(f" [调试] 裁剪云端确认异常: {e}")
  526. return None
  527. def _match_verify(title: str, n_brand: str, n_key: str, shot_path: str, cloud_cache: dict,
  528. card_y: int = None) -> tuple:
  529. """
  530. 品牌/药品名匹配(只判断品牌+药品名核心词,规格/功效文字不参与)。
  531. 返回 (判定, 不匹配原因):判定 "ok"/"fuzzy"/"fail",原因如 "品牌"/"药品名"/"品牌、药品名"
  532. - 本地精确匹配 → "ok"(免费)
  533. - 完全不像 → "fail"(免费,不花云端)
  534. - 差1字/漏字 → 用本地坐标裁剪该商品标题区域,云端识别确认:
  535. 裁剪区含目标词(本地认错字,如理王→理洫王/甲疏咪唑)→ "ok"
  536. 裁剪区不含目标词(确实不是该商品,如温胃舒vs养胃舒)→ "fail"
  537. - 裁剪确认不可用 → 漏字场景容错放行 "fuzzy",换字场景 "fail"
  538. """
  539. nt = normalize_match_text(title)
  540. brand_match = (not n_brand) or (n_brand in nt)
  541. key_match = (not n_key) or (n_key in nt)
  542. if brand_match and key_match:
  543. return "ok", ""
  544. def _unmatched_reason():
  545. parts = []
  546. if n_brand and not brand_match:
  547. parts.append("品牌")
  548. if n_key and not key_match:
  549. parts.append("药品名")
  550. return "、".join(parts) or "品牌/药品名"
  551. # 便宜判断:完全不像(非差1字也非漏字)→ 直接过滤,不花云端
  552. near_brand = (not n_brand) or _match_fuzzy(n_brand, nt) or _is_missing_char(n_brand, nt)
  553. near_key = (not n_key) or _match_fuzzy(n_key, nt) or _is_missing_char(n_key, nt)
  554. if not (near_brand and near_key):
  555. return "fail", _unmatched_reason()
  556. # 差1字/漏字 → 用本地坐标裁剪该商品标题区域,云端确认(标准版即可,无需坐标)
  557. if card_y is not None:
  558. crop_text = _cloud_crop_confirm(shot_path, card_y)
  559. if crop_text is not None:
  560. if (not n_brand or n_brand in crop_text) and (not n_key or n_key in crop_text):
  561. # 把裁剪确认文本交给标题修正逻辑(如"理王"→"理洫王")
  562. cloud_cache["texts"] = [crop_text]
  563. return "ok", ""
  564. print(f" [调试] 卡片y={card_y} 裁剪云端=[{crop_text[:40]}] 不含目标词")
  565. return "fail", _unmatched_reason()
  566. print(f" [调试] 卡片y={card_y} 裁剪云端识别失败")
  567. # 裁剪确认不可用 → 原逻辑:漏字(独立词)→ 整页云端兜底;换字 → 过滤
  568. missing = []
  569. if n_brand and not brand_match and _is_missing_char(n_brand, nt):
  570. missing.append("品牌")
  571. if n_key and not key_match and _is_missing_char(n_key, nt):
  572. missing.append("药品名")
  573. if not missing:
  574. return "fail", _unmatched_reason() # 换字 → 过滤(安全默认)
  575. # 漏字 → 整页云端兜底(cloud_cache 保证每批只调一次)
  576. _ensure_cloud(cloud_cache, shot_path)
  577. if cloud_cache["texts"] is None:
  578. return "fuzzy", ""
  579. c_joined = "".join(cloud_cache["texts"])
  580. if (not n_brand or n_brand in c_joined) and (not n_key or n_key in c_joined):
  581. return "ok", ""
  582. return "fail", _unmatched_reason()
  583. def _screen_state(texts: list) -> str:
  584. """
  585. 根据 OCR 文本判断屏幕状态(统一的状态识别,各步骤共用):
  586. detail=商品详情页 shop=店铺页 list=搜索结果列表页 unknown=其他
  587. """
  588. joined = "".join(texts)
  589. if any(("加入购物车" in t) or ("立即购买" in t) or ("选规格" in t) or ("加入购物袋" in t) for t in texts):
  590. return "detail"
  591. if ("刚刚搜过" in joined) and ("评价" in joined):
  592. return "shop"
  593. if "筛选" in joined:
  594. return "list"
  595. return "unknown"
  596. def _is_list_page(ex: SafeExecutor) -> bool:
  597. """当前是否在搜索结果列表页(全屏检测「筛选」,店铺页/详情页不含此词)"""
  598. import os as _os
  599. tmp = _shot_path(ex, "check_list.png")
  600. ex.driver.screenshot(tmp)
  601. if cv2.imread(tmp) is None:
  602. ex.driver.screenshot(tmp)
  603. texts = [r["text"] for r in OCR.recognize(tmp, detail="all")]
  604. return any("筛选" in t for t in texts)
  605. def _find_ad_close(shot_path: str) -> Optional[dict]:
  606. """
  607. 广告弹窗打叉按钮:二值化模板匹配,形状匹配不受颜色/背景干扰。
  608. 双模板双二值化(固定阈值100 + OTSU自适应,各自同阈值组合),不同弹窗打叉深浅不同,取最佳。
  609. 命中返回 {"x","y"}。
  610. """
  611. base_dir = Path(__file__).parent / "files"
  612. screen = cv2.imread(shot_path, cv2.IMREAD_GRAYSCALE)
  613. if screen is None:
  614. return None
  615. h_s, w_s = screen.shape[:2]
  616. base_w = 1220.0 # 模板裁自1220宽屏,其他分辨率按比例缩放
  617. scale_ratio = w_s / base_w
  618. scales = [round(scale_ratio * s, 2) for s in [0.8, 0.9, 1.0, 1.1, 1.2]]
  619. best_val, best_loc, best_sw, best_sh = 0, None, 0, 0
  620. # (模板文件, 截图二值化方式):同阈值组合(固定×固定、OTSU×OTSU)
  621. pairs = (
  622. ("ad_close_bin.png", lambda g: cv2.threshold(g, 100, 255, cv2.THRESH_BINARY_INV)[1]),
  623. ("ad_close_otsu.png", lambda g: cv2.threshold(g, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]),
  624. )
  625. for tpl_name, binarize in pairs:
  626. template = cv2.imread(str(base_dir / tpl_name), cv2.IMREAD_GRAYSCALE)
  627. if template is None:
  628. continue
  629. screen_bin = binarize(screen)
  630. for scale in scales:
  631. scaled = cv2.resize(template, None, fx=scale, fy=scale)
  632. sw, sh = scaled.shape[1], scaled.shape[0]
  633. if sh > h_s or sw > w_s:
  634. continue
  635. res = cv2.matchTemplate(screen_bin, scaled, cv2.TM_CCOEFF_NORMED)
  636. _, mv, _, ml = cv2.minMaxLoc(res)
  637. if mv > best_val:
  638. best_val, best_loc, best_sw, best_sh = mv, ml, sw, sh
  639. # 阈值0.4:二值化形状匹配值高(实测0.65+),误匹配低
  640. if best_val >= 0.4 and best_loc is not None:
  641. sx = best_loc[0] + best_sw // 2
  642. sy = best_loc[1] + best_sh // 2
  643. # 位置约束:打叉在弹窗正下方(下半屏 y>0.4h),上半屏匹配视为误报
  644. if sy < int(h_s * 0.4):
  645. return None
  646. return {"x": sx, "y": sy}
  647. return None
  648. def _click_reload(ex: SafeExecutor) -> bool:
  649. """检测「出错了/正在检修中」页面并点击「重新加载」(验证码通过后可能出现)"""
  650. try:
  651. shot = _shot_path(ex, "reload_check.png")
  652. ex.driver.screenshot(shot)
  653. if cv2.imread(shot) is None:
  654. ex.driver.screenshot(shot)
  655. btn = None
  656. for r in OCR.recognize(shot, detail="all"):
  657. if "重新加载" in r["text"]:
  658. box = r["bbox"]
  659. btn = {"x": (box[0][0] + box[2][0]) // 2, "y": (box[0][1] + box[2][1]) // 2}
  660. break
  661. if not btn:
  662. return False
  663. print(f" 点击「重新加载」: ({btn['x']}, {btn['y']})")
  664. ex.tap(btn["x"], btn["y"])
  665. time.sleep(3)
  666. return True
  667. except Exception as e:
  668. print(f" ⚠ 重新加载处理异常: {e}")
  669. return False
  670. def _has_popup_mask(shot_path: str) -> bool:
  671. """
  672. 弹窗遮罩检测:弹窗出现时周围被半透明遮罩变暗(暗区比例大幅上升)。
  673. 实测:有弹窗暗区0.18-0.33,无弹窗<0.1,阈值0.12安全区分。
  674. """
  675. img = cv2.imread(shot_path, cv2.IMREAD_GRAYSCALE)
  676. if img is None:
  677. return False
  678. dark_ratio = float((img < 80).mean())
  679. return dark_ratio > 0.12
  680. def _close_ad_popup(ex: SafeExecutor) -> bool:
  681. """
  682. 检测并关闭广告弹窗:
  683. ① 遮罩检测(弹窗周围变暗,本地计算零成本,最快最准)
  684. ② AI 找关闭按钮(云端OCR → AI判断)
  685. ③ 模板匹配打叉兜底
  686. """
  687. try:
  688. shot = _shot_path(ex, "ad_check.png")
  689. ex.driver.screenshot(shot)
  690. if cv2.imread(shot) is None:
  691. ex.driver.screenshot(shot)
  692. # 1. 遮罩检测:无弹窗直接返回(不浪费AI调用)
  693. if not _has_popup_mask(shot):
  694. return False
  695. print(" 检测到弹窗(周围遮罩变暗)")
  696. # 2. AI 找关闭按钮(云端OCR → AI判断坐标)
  697. raw = OCR.recognize(shot, detail="all", engine="cloud")
  698. info = detect_popup(raw)
  699. if info["has_popup"] and info.get("close_xy") and len(info["close_xy"]) == 2:
  700. print(f" 弹窗检测(AI): {info['reason']}")
  701. print(f" 关闭广告弹窗(AI坐标): ({info['close_xy'][0]}, {info['close_xy'][1]})")
  702. ex.tap(int(info["close_xy"][0]), int(info["close_xy"][1]))
  703. time.sleep(1.5)
  704. return True
  705. # 3. 模板匹配打叉兜底(弹窗正下方居中的 ×)
  706. btn = _find_ad_close(shot)
  707. if btn:
  708. print(f" 关闭广告弹窗(模板): ({btn['x']}, {btn['y']})")
  709. ex.tap(btn["x"], btn["y"])
  710. time.sleep(1.5)
  711. return True
  712. print(" ⚠ 检测到弹窗但未找到关闭按钮(AI无坐标+模板未命中)")
  713. return True
  714. except Exception as e:
  715. print(f" ⚠ 广告弹窗处理异常: {e}")
  716. return False
  717. def _adb_swipe_up(ex: SafeExecutor, distance: int):
  718. """ADB 手指从下往上滑,内容下滑"""
  719. import subprocess
  720. w, h = ex.driver.window_size()
  721. swipe_x = w // 2
  722. seg = 3
  723. seg_px = distance // seg
  724. for i in range(seg):
  725. s = int(h * 0.8) - i * 80
  726. e = s - seg_px
  727. if e < 50:
  728. e = 50
  729. subprocess.run(
  730. ["adb", "-s", ex.device_id, "shell", "input", "swipe",
  731. str(swipe_x), str(s), str(swipe_x), str(e), "400"],
  732. capture_output=True, timeout=10
  733. )
  734. time.sleep(0.35)
  735. time.sleep(1.4)
  736. def _adb_swipe_left(ex: SafeExecutor, y_ratio: float = 0.3):
  737. """从右往左滑:切换商品图片轮播(图片在详情页上半部分)"""
  738. import subprocess
  739. w, h = ex.driver.window_size()
  740. y = int(h * y_ratio)
  741. subprocess.run(
  742. ["adb", "-s", ex.device_id, "shell", "input", "swipe",
  743. str(int(w * 0.85)), str(y), str(int(w * 0.15)), str(y), "300"],
  744. capture_output=True, timeout=10
  745. )
  746. time.sleep(1.2)
  747. def _adb_swipe_up_short(ex: SafeExecutor):
  748. """上滑半屏:说明书页内容可能需滑动才全部可见"""
  749. import subprocess
  750. w, h = ex.driver.window_size()
  751. subprocess.run(
  752. ["adb", "-s", ex.device_id, "shell", "input", "swipe",
  753. str(w // 2), str(int(h * 0.7)), str(w // 2), str(int(h * 0.3)), "300"],
  754. capture_output=True, timeout=10
  755. )
  756. time.sleep(1)
  757. def _find_close_btn(shot_path: str) -> Optional[dict]:
  758. """
  759. 说明书页右上角找打叉关闭按钮(多尺度模板匹配,适配多分辨率)。
  760. 模板 files/close.png,命中返回 {"x","y"},失败返回 None。
  761. """
  762. import os as _os
  763. tpl_path = str(Path(__file__).parent / "files" / "close.png")
  764. screen = cv2.imread(shot_path)
  765. template = cv2.imread(tpl_path)
  766. if screen is None or template is None:
  767. return None
  768. h_s, w_s = screen.shape[:2]
  769. # 右上角区域(打叉永远在右上角)
  770. roi_x1, roi_y1 = w_s * 2 // 3, 0
  771. roi = screen[roi_y1:h_s // 4, roi_x1:w_s]
  772. # 多尺度模板匹配(分辨率适配:以720p为基准按屏宽比例缩放)
  773. base_w = 720.0
  774. scale_ratio = w_s / base_w
  775. scales = [round(scale_ratio * s, 2) for s in [0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5]]
  776. best_val, best_loc, best_sw, best_sh = 0, None, 0, 0
  777. for scale in scales:
  778. scaled = cv2.resize(template, None, fx=scale, fy=scale)
  779. sw, sh = scaled.shape[1], scaled.shape[0]
  780. if sh > roi.shape[0] or sw > roi.shape[1]:
  781. continue
  782. res = cv2.matchTemplate(roi, scaled, cv2.TM_CCOEFF_NORMED)
  783. _, mv, _, ml = cv2.minMaxLoc(res)
  784. if mv > best_val:
  785. best_val, best_loc, best_sw, best_sh = mv, ml, sw, sh
  786. t_edge = cv2.Canny(scaled, 30, 100)
  787. r_edge = cv2.Canny(roi, 30, 100)
  788. if t_edge.shape[0] <= r_edge.shape[0] and t_edge.shape[1] <= r_edge.shape[1]:
  789. res2 = cv2.matchTemplate(r_edge, t_edge, cv2.TM_CCOEFF_NORMED)
  790. _, mv2, _, ml2 = cv2.minMaxLoc(res2)
  791. if mv2 > best_val:
  792. best_val, best_loc, best_sw, best_sh = mv2, ml2, sw, sh
  793. if best_val >= 0.26 and best_loc is not None:
  794. sx = roi_x1 + best_loc[0] + best_sw // 2
  795. sy = best_loc[1] + best_sh // 2
  796. return {"x": sx, "y": sy}
  797. return None
  798. def _collect_instructions(ex: SafeExecutor) -> dict:
  799. """
  800. 商品详情页采集说明书(采完链接后调用):
  801. back关分享弹窗 → 左滑商品图片 → 点「查看详细说明」
  802. → 说明书页截图OCR → 提取批准文号/有效期(最多上滑3次兜底)
  803. 返回 {"approval_no": "", "validity": ""},任何失败留空不抛异常
  804. """
  805. w, h = ex.driver.window_size()
  806. pfx = f"{ex.device_id}_inst"
  807. # in_detail: 是否确认停留在商品详情页——主流程据此决定后续资质采集是否安全执行
  808. result = {"approval_no": "", "validity": "", "in_detail": False}
  809. # 1. 确认屏幕状态:分享弹窗→back关闭;在详情页→开始;在列表页→跳过(不再back,避免乱退)
  810. for _try in range(3):
  811. shot = _shot_path(ex, "inst_check.png")
  812. ex.driver.screenshot(shot)
  813. if cv2.imread(shot) is None:
  814. ex.driver.screenshot(shot)
  815. texts = [r["text"] for r in OCR.recognize(shot, detail="all")]
  816. if any("分享到" in t for t in texts):
  817. print(" 关闭分享弹窗")
  818. ex.driver.press("back")
  819. time.sleep(1.2)
  820. continue
  821. state = _screen_state(texts)
  822. if state == "detail":
  823. result["in_detail"] = True
  824. break
  825. if state == "list":
  826. print(" ⚠ 说明书采集跳过:已在列表页(step4未成功进店),不再back")
  827. return result
  828. if _try < 2:
  829. print(f" 屏幕状态[{state}],back一次重新确认")
  830. ex.driver.press("back")
  831. time.sleep(1.2)
  832. else:
  833. print(" ⚠ 说明书采集跳过:多次确认仍不在商品详情页")
  834. return result
  835. # 2. 左滑切换商品图片,找「查看详细说明」按钮
  836. _adb_swipe_left(ex, 0.3)
  837. shot2 = _shot_path(ex, "inst_btn.png")
  838. ex.driver.screenshot(shot2)
  839. btn = _find_text_in_area(shot2, "查看详细说明", h)
  840. if not btn:
  841. print(" ⚠ 未找到「查看详细说明」,跳过说明书采集")
  842. return result
  843. print(f" 说明书按钮: ({btn['x']}, {btn['y']})")
  844. ex.tap(btn["x"], btn["y"])
  845. time.sleep(2.5)
  846. _ai_check_captcha(ex) # 说明书页可能出现验证码(无关键词检查环节)
  847. # 3. 说明书页截图 + OCR 提取(模仿美团:批准文号和有效期都找到才停,最多滑3次)
  848. for attempt in range(4):
  849. shot3 = _shot_path(ex, "inst_page.png")
  850. ex.driver.screenshot(shot3)
  851. inst = parse_instructions(OCR.recognize(shot3, detail="all", engine="cloud"))
  852. if inst["approval_no"] and inst["validity"]:
  853. print(f" 说明书: 批准文号={inst['approval_no']} 有效期={inst['validity']}")
  854. result = inst
  855. break
  856. if attempt < 3:
  857. missing = [k for k in ("approval_no", "validity") if not inst[k]]
  858. print(f" 说明书字段不全(第{attempt+1}次,缺{missing}),上滑重试")
  859. _adb_swipe_up_short(ex)
  860. else:
  861. print(" ⚠ 说明书页4次均未解析到批准文号/有效期")
  862. result = inst
  863. # 4. 说明书采集完成:点右上角打叉关闭说明书页(不能back——back会直接回列表页)
  864. # 本任务内缓存:第一个商品找到后,后续商品直接复用坐标
  865. global _CLOSE_BTN_CACHE
  866. close_btn = _CLOSE_BTN_CACHE
  867. if close_btn is None:
  868. close_shot = _shot_path(ex, "inst_close.png")
  869. ex.driver.screenshot(close_shot)
  870. close_btn = _find_close_btn(close_shot)
  871. if close_btn:
  872. _CLOSE_BTN_CACHE = close_btn
  873. print(f" 关闭说明书页: ({close_btn['x']}, {close_btn['y']})(本任务已缓存,后续商品复用)")
  874. else:
  875. print(" ⚠ 未找到打叉按钮(后续步骤会按屏幕状态自行处理)")
  876. else:
  877. print(f" 关闭说明书页: ({close_btn['x']}, {close_btn['y']})(复用本任务缓存坐标)")
  878. if close_btn:
  879. ex.tap(close_btn["x"], close_btn["y"])
  880. time.sleep(1.2)
  881. return result
  882. def _collect_snapshot(ex: SafeExecutor, title: str) -> str:
  883. """
  884. 网页快照(采集说明书之后调用):
  885. 先识别屏幕状态——已在详情页直接拍;说明书页等未知页则back一次回详情页再拍;
  886. 在列表页/店铺页等明确非详情页位置直接跳过(不再back,避免把列表页退到首页)
  887. """
  888. try:
  889. # 1. 先截图识别状态,决定是否需要 back
  890. shot = _shot_path(ex, "snap_check.png")
  891. for _try in range(2):
  892. ex.driver.screenshot(shot)
  893. if cv2.imread(shot) is None:
  894. ex.driver.screenshot(shot)
  895. texts = [r["text"] for r in OCR.recognize(shot, detail="all")]
  896. state = _screen_state(texts)
  897. if state == "detail":
  898. break
  899. if state in ("list", "shop"):
  900. print(f" ⚠ 快照跳过:屏幕状态[{state}],不在详情页也不再back")
  901. return ""
  902. if _try == 0:
  903. # 说明书页/其他未知页:back 一次回详情页再确认
  904. print(f" 快照:屏幕状态[{state}],back回详情页")
  905. ex.driver.press("back")
  906. time.sleep(1.2)
  907. else:
  908. print(f" ⚠ 快照跳过:back后屏幕状态[{state}],不在详情页")
  909. return ""
  910. else:
  911. print(" ⚠ 快照跳过:无法确认在详情页")
  912. return ""
  913. # 2. 滚动截图 + 上传OSS(美团同款逻辑在 snapshot 模块)
  914. url, snap_reason = collect_snapshot(ex.driver, title, ex.device_id)
  915. print(f" 📷 快照: {url if url else f'失败({snap_reason})'}")
  916. if url:
  917. # 快照滚动改变了页面位置:back 回店铺页,再开始资质采集
  918. ex.driver.press("back")
  919. time.sleep(1.2)
  920. return url
  921. except Exception as e:
  922. print(f" ⚠ 快照采集异常: {e}")
  923. return ""
  924. def _collect_license(ex: SafeExecutor, shop_name: str) -> dict:
  925. """
  926. 采集商家资质(采完说明书后调用,屏幕在说明书页):
  927. back回店铺内 → OCR上半区找店铺名点击 → 下半区找「查看营业资质」点击
  928. → 云端OCR找「资质编号」取值 → 点编号下方约3cm打开营业执照 → 百度营业执照专用接口OCR
  929. 返回 {"license_no": "", "license": {}},任何失败留空不抛异常
  930. """
  931. w, h = ex.driver.window_size()
  932. pfx = f"{ex.device_id}_lic"
  933. result = {"license_no": "", "license": {}}
  934. # 0. 已采集过的店铺:直接从数据库获取资质,不重复采集(美团/PDD同款)
  935. try:
  936. exist = get_existing_license(shop_name)
  937. if exist.get("license_no") or exist.get("company"):
  938. print(f" 资质已存在,从数据库获取: 编号={exist['license_no'][:24]} 公司={exist['company'][:20]}")
  939. result["license_no"] = exist["license_no"]
  940. lic = {}
  941. if exist.get("company"):
  942. lic["单位名称"] = exist["company"]
  943. if exist.get("address"):
  944. lic["地址"] = exist["address"]
  945. result["license"] = lic
  946. return result
  947. except Exception as e:
  948. print(f" ⚠ 查询已有资质失败: {e}")
  949. # 1+2. 回到店铺页并采资质(最多2轮):每轮先找店铺名,找不到/点后无资质入口就 back 回退一层再看
  950. # 注意不能无条件back:屏幕已在店铺页时再退会到列表页
  951. def _find_license_btn():
  952. # 先搜下半区,找不到整张图分析(「查看营业资质」位置不固定,有时在上半区)
  953. for rect in ([0, int(h * 0.45), w, h], None):
  954. for r in OCR.recognize(shot2, rect=rect, detail="all"):
  955. if "查看营业资质" in r["text"]:
  956. box = r["bbox"]
  957. return {"x": (box[0][0] + box[2][0]) // 2, "y": (box[0][1] + box[2][1]) // 2}
  958. return None
  959. lic_btn = None
  960. for attempt in range(2):
  961. if _is_list_page(ex):
  962. print(" ⚠ 资质采集跳过:已在列表页(不点列表卡片)")
  963. return result
  964. shot = _shot_path(ex, "lic_shopname.png")
  965. ex.driver.screenshot(shot)
  966. shop_btn = _find_text_in_area(shot, shop_name, h // 2)
  967. if shop_btn:
  968. print(f" 店铺名: ({shop_btn['x']}, {shop_btn['y']}) (第{attempt+1}轮)")
  969. ex.tap(shop_btn["x"], shop_btn["y"])
  970. time.sleep(2.5)
  971. # 下半区找「查看营业资质」,找不到先上滑一次再看
  972. shot2 = _shot_path(ex, "inst_btn.png")
  973. ex.driver.screenshot(shot2)
  974. lic_btn = _find_license_btn()
  975. if not lic_btn:
  976. print(f" 未找到「查看营业资质」(第{attempt+1}轮),上滑再看")
  977. _adb_swipe_up_short(ex)
  978. shot2 = _shot_path(ex, "inst_btn.png")
  979. ex.driver.screenshot(shot2)
  980. lic_btn = _find_license_btn()
  981. if lic_btn:
  982. break
  983. if attempt == 0:
  984. # 屏幕可能在详情页/说明书页:back 回退一层再看
  985. print(" back一次回退后再试")
  986. ex.driver.press("back")
  987. time.sleep(1.5)
  988. else:
  989. print(" ⚠ 资质采集跳过:未找到店铺名或「查看营业资质」")
  990. return result
  991. print(f" 查看营业资质: ({lic_btn['x']}, {lic_btn['y']})")
  992. ex.tap(lic_btn["x"], lic_btn["y"])
  993. time.sleep(2.5)
  994. _ai_check_captcha(ex) # 资质页可能出现验证码(无关键词检查环节)
  995. # 4. 资质页:云端OCR找「资质编号」取值
  996. shot3 = _shot_path(ex, "lic_no.png")
  997. ex.driver.screenshot(shot3)
  998. raw3 = OCR.recognize(shot3, detail="all", engine="cloud")
  999. license_no = extract_value_after(raw3, "资质编号")
  1000. if license_no:
  1001. # OCR可能把长编号读散(如 "91450800MA5 KEHAHXT"),编号不该有空格,清洗掉
  1002. license_no = license_no.replace(" ", "")
  1003. if not license_no:
  1004. print(" ⚠ 资质采集跳过:未找到资质编号")
  1005. return result
  1006. print(f" 资质编号: {license_no}")
  1007. result["license_no"] = license_no
  1008. # 5. 点资质编号下方约3cm(≈0.2屏高)打开营业执照大图
  1009. # 注意:云端OCR(标准版)无坐标,必须用本地OCR找资质编号的真实位置
  1010. no_x, no_y = None, None
  1011. for r in OCR.recognize(shot3, detail="all"): # 本地引擎,有真实bbox
  1012. t = r["text"].strip().rstrip(":: \t")
  1013. if t.startswith("资质编号"):
  1014. box = r["bbox"]
  1015. no_x = (box[0][0] + box[2][0]) // 2
  1016. no_y = (box[0][1] + box[2][1]) // 2
  1017. break
  1018. if no_y is None:
  1019. print(" ⚠ 资质采集跳过:本地OCR未找到资质编号位置")
  1020. return result
  1021. print(f" 资质编号位置: ({no_x}, {no_y}),点击下方打开执照")
  1022. ex.tap(no_x, min(no_y + int(h * 0.2), h - 50))
  1023. time.sleep(2.5)
  1024. # 6. 营业执照截图 + 百度营业执照专用接口(美团同款;执照在屏幕中下部,先裁剪再识别,逐级兜底)
  1025. shot4 = _shot_path(ex, "lic_license.png")
  1026. ex.driver.screenshot(shot4)
  1027. lic = {}
  1028. for t_ratio, b_ratio in [(0.35, 0.8), (0.4, 1.0), (0.0, 1.0)]:
  1029. lic = OCR.recognize_license(shot4, rect=[0, int(h * t_ratio), w, int(h * b_ratio)])
  1030. if lic:
  1031. break
  1032. if lic:
  1033. print(f" 营业执照: {lic}")
  1034. result["license"] = lic
  1035. else:
  1036. print(" ⚠ 营业执照OCR为空")
  1037. return result
  1038. def _detect_right_col_split(raw: list, w: int) -> Optional[int]:
  1039. """
  1040. 定位列表页"图片|文字"分界x:每张卡片都有价格,¥全在右列且x一致。
  1041. 取¥块左边缘x的中位数 − 15 作为分界(图片区在左、标题/价格在右)。
  1042. 实测3台设备:分界499时图片文字最右仅474,过滤干净。返回None=检测不到(用整图)。
  1043. """
  1044. price_x = sorted(r["box"][0] for r in raw if "¥" in r["text"] or "¥" in r["text"])
  1045. if len(price_x) < 3:
  1046. return None
  1047. return max(price_x[len(price_x) // 2] - 15, int(w * 0.3))
  1048. def _get_named_shops(ex: SafeExecutor, shot_name: str, keyword: str = "") -> tuple:
  1049. """截图 + OCR + AI → 返回 (有店铺名的列表, 本地OCR原始结果带坐标)"""
  1050. shot = _screenshot(ex, shot_name)
  1051. raw = OCR.recognize(shot, detail="all")
  1052. w, h = ex.driver.window_size()
  1053. # 只保留右列(商品描述列):用¥定位分界,过滤左侧图片文字(包装字/英文/乱码)
  1054. split_x = _detect_right_col_split(raw, w)
  1055. if split_x:
  1056. filtered = [r for r in raw if (r["box"][0] + r["box"][2]) // 2 >= split_x]
  1057. if filtered:
  1058. print(f"[step3] 右列识别: 分界x={split_x},过滤掉{len(raw) - len(filtered)}块图片文字")
  1059. raw = filtered
  1060. # 在截图副本上画红线保存(出错时核对分界是否偏左/偏右)
  1061. try:
  1062. img = cv2.imread(shot)
  1063. if img is not None:
  1064. cv2.line(img, (split_x, 0), (split_x, img.shape[0]), (0, 0, 255), 3)
  1065. cv2.putText(img, f"split={split_x}", (split_x + 5, 50),
  1066. cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2)
  1067. cv2.imwrite(shot.replace(".png", "_split.png"), img)
  1068. except Exception:
  1069. pass
  1070. if USE_VISION_PARSER:
  1071. # 方案A:截图送视觉模型(生僻字更准),裁掉左列商品图片只留右列文字区
  1072. # crop_x=split_x 来自OCR分界检测,AI只看文字区,y取标题和店铺名的中点
  1073. shops = VisionParser().parse_shops(shot, screen_size=(w, h), keyword=keyword, crop_x=split_x or 0)
  1074. # AI估y不准会点到上一个店铺,用OCR精确坐标修正
  1075. _correct_vision_coords_with_ocr(shops, raw)
  1076. # 视觉模型失败时 fallback 到 OCR+文本AI
  1077. if not shops:
  1078. print("[step3] 视觉模型未识别到店铺,fallback 到 OCR+文本AI")
  1079. shops = AIParser().parse_shops(raw, screen_size=(w, h), keyword=keyword)
  1080. else:
  1081. # 原方案:本地OCR文本 → 文本AI解析
  1082. shops = AIParser().parse_shops(raw, screen_size=(w, h), keyword=keyword)
  1083. # 只保留有效店铺名+价格:店铺名必须含中文或字母(排除纯数字/标点/空格)
  1084. import re as _re
  1085. valid = []
  1086. for s in shops:
  1087. name = (s[0] or "").strip()
  1088. price = (s[2] or "").strip()
  1089. if name and _re.search(r'[一-鿿＀-￯a-zA-Z]', name) and price:
  1090. valid.append(s)
  1091. return valid, raw
  1092. def _find_title_y(raw: list, title: str) -> Optional[int]:
  1093. """在本地OCR结果里找与标题开头重合最多的块的y坐标(标题行位置,用于裁剪)"""
  1094. nt = normalize_match_text(title)
  1095. best_len, best_cy = 0, None
  1096. for r in raw:
  1097. t = normalize_match_text(r["text"])
  1098. if not t:
  1099. continue
  1100. n = 0
  1101. for a, b in zip(t, nt):
  1102. if a == b:
  1103. n += 1
  1104. else:
  1105. break
  1106. if n > best_len:
  1107. best_len = n
  1108. best_cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2
  1109. return best_cy if best_len >= 2 else None
  1110. def _correct_vision_coords_with_ocr(shops: list, raw: list) -> None:
  1111. """用OCR精确坐标修正VisionParser返回的y坐标。
  1112. AI估y不准会点到上一个店铺,用OCR里店铺名和标题的精确y重新算中点。
  1113. 直接修改 shops 列表中的坐标,不返回新列表。"""
  1114. for s in shops:
  1115. shop_name = str(s[0] or "").strip()
  1116. title = str(s[1] or "").strip()
  1117. # 在OCR里找店铺名 → 精确y(子串匹配,取最长匹配的块)
  1118. shop_y = None
  1119. if shop_name and len(shop_name) >= 2:
  1120. ns = normalize_match_text(shop_name)
  1121. best_len, best_y = 0, None
  1122. for r in raw:
  1123. t = normalize_match_text(r["text"])
  1124. if not t or len(t) < 2:
  1125. continue
  1126. if ns in t or t in ns:
  1127. if len(t) > best_len:
  1128. best_len = len(t)
  1129. best_y = (r["bbox"][0][1] + r["bbox"][2][1]) // 2
  1130. shop_y = best_y
  1131. # 在OCR里找标题 → 精确y(复用已有函数)
  1132. title_y = _find_title_y(raw, title) if title else None
  1133. # 用OCR坐标修正点击坐标
  1134. if shop_y is not None and title_y is not None:
  1135. s[3][1] = (title_y + shop_y) // 2 # 点击y = 标题和店铺名中点
  1136. s[4] = shop_y
  1137. print(f" [coord] OCR修正: {shop_name[:10]} y={s[3][1]} (标题y={title_y} 店铺y={shop_y})")
  1138. elif shop_y is not None:
  1139. s[3][1] = max(shop_y - 150, 0) # 只有店铺名y,往上150点击
  1140. s[4] = shop_y
  1141. print(f" [coord] OCR修正(仅店铺名): {shop_name[:10]} y={s[3][1]}")
  1142. def _shop_key(shop: list) -> str:
  1143. """用店铺名+价格去重(去括号内分店名、去尾部点号)"""
  1144. import re
  1145. name = shop[0]
  1146. price = shop[2] if len(shop) > 2 else ""
  1147. name = name.replace("(", "(").replace(")", ")")
  1148. name = re.sub(r'(.*', '', name)
  1149. name = re.sub(r'[..…]+$', '', name)
  1150. return f"{name.strip()}|{price.strip()}"
  1151. def _visit_shop(ex: SafeExecutor, shop: list, visited: set, keyword: str = "", task: dict = None) -> dict:
  1152. """
  1153. 点击进入店铺 → step4 → 说明书 → 快照 → 资质 → 返回完整数据 dict
  1154. task: 调度任务 dict(task_id/enterprise_id/collect_round等),手动模式传 None
  1155. """
  1156. """点击进入店铺 → step4 → 返回完整数据 dict"""
  1157. key = _shop_key(shop)
  1158. if key in visited:
  1159. return None
  1160. visited.add(key)
  1161. shop_name = shop[0]
  1162. product_title = shop[1]
  1163. price = shop[2]
  1164. sales = str(shop[5]) if len(shop) > 5 and shop[5] else ""
  1165. click_x, click_y = shop[3]
  1166. print(f" → 进入 [{shop_name}] 商品: {product_title[:30]} 价格: {price} 月售: {sales}")
  1167. ex.tap(click_x, click_y)
  1168. try:
  1169. qr_url = step4_parse_qr(ex, product_title, shop_name, shop_xy=[click_x, click_y])
  1170. except Exception as e:
  1171. print(f" ⚠ step4异常: {e},跳过此店铺")
  1172. qr_url = ""
  1173. if qr_url == "__TERMINATE__":
  1174. print(f" ⚠ 遇到终止信号,停止遍历")
  1175. return {"__terminate__": True}
  1176. if qr_url == "__RESTART__":
  1177. print(f" ⚠ 页面异常,触发重启恢复")
  1178. return {"__restart__": True}
  1179. if qr_url:
  1180. print(f" ✅ QR: {qr_url[:80]}")
  1181. print(f" 📦 采集完成: {shop_name} | {product_title[:30]} | {price} | 月售{sales} | {qr_url[:60]}")
  1182. else:
  1183. print(f" ⚠ 未获取到二维码链接")
  1184. print(f" 📦 采集完成(无链接): {shop_name} | {product_title[:30]} | {price} | 月售{sales}")
  1185. # 内存熔断:本地OCR已OOM → 说明书/快照/资质全部跳过(状态检测不可靠,避免乱back乱点)
  1186. if getattr(OCR, "oom", False):
  1187. print(" ⚠ 内存不足熔断:跳过说明书/快照/资质,直接返回列表页(请关闭部分程序释放内存)")
  1188. inst = {"approval_no": "", "validity": "", "in_detail": False}
  1189. snapshot_url = ""
  1190. lic = {"license_no": "", "license": {}}
  1191. else:
  1192. # 采完链接后采集说明书(批准文号/有效期),失败不阻塞
  1193. try:
  1194. inst = _collect_instructions(ex)
  1195. except Exception as e:
  1196. print(f" ⚠ 说明书采集异常: {e}(疑似内存不足,请关闭部分程序)")
  1197. inst = {"approval_no": "", "validity": "", "in_detail": False}
  1198. print(f" 📄 说明书: 批准文号={inst['approval_no']} 有效期={inst['validity']}")
  1199. # 采完说明书后采集网页快照(美团同款顺序:说明书→快照→资质),失败不阻塞
  1200. snapshot_url = _collect_snapshot(ex, product_title)
  1201. # 采完快照后采集商家资质(_collect_license 会先 back 回店铺页,再按屏幕状态自校验:列表页/无店铺名都跳过)
  1202. try:
  1203. lic = _collect_license(ex, shop_name)
  1204. except Exception as e:
  1205. print(f" ⚠ 资质采集异常: {e}")
  1206. lic = {"license_no": "", "license": {}}
  1207. print(f" 📋 资质编号: {lic['license_no']} 单位名称: {lic['license'].get('单位名称', '')} 信用代码: {lic['license'].get('社会信用代码', '')}")
  1208. # 返回搜索页:先检测再退(已是列表页则一步不退;退到首页立即停,防止退过头退出app)
  1209. for _ in range(5):
  1210. try:
  1211. pos = _where_am_i(ex)
  1212. except Exception as e:
  1213. print(f" ⚠ 位置检测异常({e}),停止返回")
  1214. break
  1215. if pos == "list":
  1216. break
  1217. if pos == "home":
  1218. print(" ⚠ 已退到首页(可能退过头),停止返回")
  1219. break
  1220. ex.driver.press("back")
  1221. time.sleep(1.4)
  1222. task = task or {}
  1223. return {
  1224. "shop": shop_name,
  1225. "title": product_title,
  1226. "price": price,
  1227. "sales": sales,
  1228. "approval_no": inst["approval_no"],
  1229. "validity": inst["validity"],
  1230. "license_no": lic["license_no"],
  1231. "license": lic["license"],
  1232. "snapshot_url": snapshot_url,
  1233. "search_name": keyword,
  1234. "link": qr_url or "",
  1235. "task_id": task.get("task_id"),
  1236. "enterprise_id": task.get("enterprise_id"),
  1237. "collect_round": task.get("collect_round"),
  1238. "collect_equipment_account_id": task.get("collect_equipment_account_id"),
  1239. "collect_region_id": task.get("collect_region_id"),
  1240. "collect_config_info": task.get("collect_config_info", ""),
  1241. }
  1242. def _captcha_log_path(device_id: str) -> Path:
  1243. """每台设备独立的验证码日志文件(独立计数,互不影响)"""
  1244. return CAPTCHA_LOG_DIR / f"captcha_log_{device_id}.txt"
  1245. def _log_captcha(ex: SafeExecutor) -> bool:
  1246. """
  1247. 记录验证码出现时间到日志,检查一天≥8次停止。
  1248. 返回 True=已触发停止(上层不再休息),False=正常(处理完成后按频率休息)。
  1249. """
  1250. global CAPTCHA_ABORTED, CAPTCHA_ABORT_REASON
  1251. import os as _os
  1252. ts = time.strftime("%Y-%m-%d %H:%M:%S")
  1253. line = f"{ts} 验证码出现"
  1254. try:
  1255. _os.makedirs(_os.path.dirname(_captcha_log_path(ex.device_id)), exist_ok=True)
  1256. with open(_captcha_log_path(ex.device_id), "a", encoding="utf-8") as f:
  1257. f.write(line + "\n")
  1258. except Exception as e:
  1259. print(f" [验证码记录] 写日志失败: {e}")
  1260. print(f" [验证码记录] {line}")
  1261. # 一天内 ≥8次 → 立即停止采集回告(风控可能封号)
  1262. today_count = _captcha_count_today(ex.device_id)
  1263. print(f" [验证码记录] 设备{ex.device_id}今天已出现{today_count}次")
  1264. if today_count >= CAPTCHA_DAILY_LIMIT:
  1265. CAPTCHA_ABORTED = True
  1266. CAPTCHA_ABORT_REASON = f"一天内验证码达{today_count}次,进入风控可能封号"
  1267. print(f" ⚠ {CAPTCHA_ABORT_REASON},停止采集")
  1268. return True
  1269. return False
  1270. def _captcha_count_today(device_id: str) -> int:
  1271. """统计该设备日志中今天(按日期)的验证码次数"""
  1272. try:
  1273. today = time.strftime("%Y-%m-%d")
  1274. count = 0
  1275. with open(_captcha_log_path(device_id), encoding="utf-8") as f:
  1276. for line in f:
  1277. if line.startswith(today):
  1278. count += 1
  1279. return count
  1280. except Exception:
  1281. return 0
  1282. def _save_captcha_check(ex: SafeExecutor) -> None:
  1283. """每次判定出现验证码时截图保存到 logs/captcha_check/{日期}/,人工确认是否真验证码"""
  1284. import os as _os
  1285. try:
  1286. day = time.strftime("%Y-%m-%d")
  1287. d = CAPTCHA_CHECK_DIR / day
  1288. d.mkdir(parents=True, exist_ok=True)
  1289. ts = time.strftime("%H%M%S")
  1290. path = str(d / f"{ts}_{ex.device_id}_captcha.png")
  1291. ex.driver.screenshot(path)
  1292. print(f" [验证码截图] 已保存: {path}")
  1293. except Exception as e:
  1294. print(f" [验证码截图] 保存失败: {e}")
  1295. def _handle_captcha(ex: SafeExecutor, ocr_texts: list) -> bool:
  1296. """处理验证码, 重试5次, 失败等人工, 返回True=已解决;
  1297. 只有自动解决成功才计数(失败走人工的不算),成功后按频率休息"""
  1298. _save_captcha_check(ex) # 判定出现验证码时截图存档(人工确认是否误判)
  1299. import sys as _sys
  1300. _sys.path.insert(0, r"D:\drug\sg\yzm")
  1301. solved = False
  1302. for attempt in range(1, 6):
  1303. print(f" [验证码] 第{attempt}次尝试...")
  1304. nine_kw = any("提交" in t or "没有新图片" in t for t in ocr_texts)
  1305. if nine_kw:
  1306. from nine_grid import solve as solve_nine
  1307. ok = solve_nine(ex.driver)
  1308. else:
  1309. from tmp_captcha_test6 import solve_slider
  1310. ok = solve_slider(ex.driver)
  1311. if ok:
  1312. print(f" ✅ 验证码已解决")
  1313. solved = True
  1314. break
  1315. print(f" ❌ 第{attempt}次失败")
  1316. time.sleep(1)
  1317. if solved:
  1318. # 只有解决成功才计数 + 一天≥8次停止检查 + 每2次休息30分钟
  1319. aborted = _log_captcha(ex)
  1320. if not aborted:
  1321. _captcha_rest(ex)
  1322. else:
  1323. print(f" ⚠ 5次自动处理失败, 请人工处理...(不计数)")
  1324. input(" 处理完成后按回车继续...")
  1325. return True
  1326. def _captcha_rest(ex: SafeExecutor) -> None:
  1327. """验证码处理完成后:每累计2次休息30分钟;休息期间每10分钟回告一次进度(防后台判假死)"""
  1328. today_count = _captcha_count_today(ex.device_id)
  1329. if today_count % CAPTCHA_REST_EVERY == 0 and today_count < CAPTCHA_DAILY_LIMIT:
  1330. print(f" ⏸ 已出现{today_count}次验证码,休息{CAPTCHA_REST_MINUTES}分钟...")
  1331. rest_left = CAPTCHA_REST_MINUTES * 60
  1332. while rest_left > 0:
  1333. chunk = min(rest_left, 600) # 每10分钟一段
  1334. time.sleep(chunk)
  1335. rest_left -= chunk
  1336. # 休息期间每10分钟回告当前进度(后台超过10分钟无回告会判假死)
  1337. if rest_left > 0 and _SCHEDULER is not None:
  1338. try:
  1339. _SCHEDULER.post_report({
  1340. "task_id": _TASK_ID,
  1341. "platform": _SCHEDULER.platform,
  1342. "username": _SCHEDULER.username,
  1343. "is_finished": 0,
  1344. "need_reassign": 0,
  1345. "current_page": CURRENT_PAGE,
  1346. "crawled_count": _CRAWLED_COUNT,
  1347. })
  1348. print(f" [休息中回告] 当前页{CURRENT_PAGE},已采{_CRAWLED_COUNT}条")
  1349. except Exception as e:
  1350. print(f" [休息中回告] 失败: {e}")
  1351. print(f" ▶ 休息结束,继续采集")
  1352. # 验证码强特征词(店铺页/列表页文字多但无这些词——AI判risk时用OCR文字二次确认防误判)
  1353. CAPTCHA_KW = ("拖动滑块", "请按住滑块", "请按照说明", "点我反馈", "进行验证",
  1354. "滑块验证", "拼图", "安全验证", "图形验证", "点击完成验证", "没有新图片", "操作频繁")
  1355. def _has_captcha_kw(texts: list) -> bool:
  1356. """OCR文字是否含验证码强特征词"""
  1357. joined = "".join(texts)
  1358. return any(k in joined for k in CAPTCHA_KW)
  1359. def _ai_check_captcha(ex: SafeExecutor) -> bool:
  1360. """
  1361. AI检测当前屏幕是否出现验证码(用于没有关键词检查的环节):
  1362. 截图 → 本地OCR → 强特征词预筛 → AI判断页面类型(risk=验证码)→ 有则自动处理。
  1363. 返回 True=检测到验证码(已处理或处理中),False=无验证码。
  1364. """
  1365. try:
  1366. shot = _shot_path(ex, "captcha_ai.png")
  1367. ex.driver.screenshot(shot)
  1368. if cv2.imread(shot) is None:
  1369. ex.driver.screenshot(shot)
  1370. raw = OCR.recognize(shot, detail="all") # 本地OCR即可(验证码文字是大字,不用百度)
  1371. # 预筛:OCR文本里连强特征词都没有 → 不是验证码,不调AI(省调用+防误判)
  1372. if not _has_captcha_kw([r["text"] for r in raw]):
  1373. return False
  1374. page = AIParser().check_page(raw)
  1375. if page.get("type") == "risk":
  1376. print(f" ⚠ AI检测到验证码页面: {page.get('detail', '')}")
  1377. _handle_captcha(ex, [r["text"] for r in raw])
  1378. time.sleep(2)
  1379. return True
  1380. return False
  1381. except Exception as e:
  1382. print(f" ⚠ AI验证码检测异常: {e}")
  1383. return False
  1384. def _check_account_kicked(ex: SafeExecutor) -> bool:
  1385. """
  1386. 检测账号是否被踢/封号:登录页元素(me.ele:id/login_onkey_login_ll)出现即判定。
  1387. 检测到 → 置 ACCOUNT_ABORTED 标志(停止采集 + 调度回告)。
  1388. """
  1389. global ACCOUNT_ABORTED
  1390. try:
  1391. if ex.driver.xpath('//*[@resource-id="me.ele:id/login_onkey_login_ll"]').exists:
  1392. print(" ⚠ 检测到账号被踢/封号(登录页),停止采集")
  1393. ACCOUNT_ABORTED = True
  1394. return True
  1395. except Exception as e:
  1396. print(f" ⚠ 封号检测异常: {e}")
  1397. return False
  1398. def step4_parse_qr(ex: SafeExecutor, product_title: str, shop_name: str = "",
  1399. shop_xy: Optional[list] = None) -> str:
  1400. """
  1401. 1. 等待加载 → OCR → AI找商品标题坐标
  1402. 2. 点击商品标题 → 进入商品详情
  1403. 3. 找右上角"分享" → 点击 → 二维码弹窗
  1404. 4. 截图 → pyzbar 解析二维码
  1405. 返回 URL 或空字符串
  1406. """
  1407. # 安全的文件名前缀(用hash避免中文路径cv2兼容问题)
  1408. # 多设备隔离:加入设备ID,防止并发时两台设备写同一个文件
  1409. import hashlib
  1410. _hash = hashlib.md5(shop_name.encode()).hexdigest()[:8] if shop_name else "unknown"
  1411. _pfx = lambda name: _shot_path(ex, f"_s4_{_hash}_{name}")
  1412. time.sleep(6)
  1413. # ── 检测页面类型:验证码/风控/正常 ──
  1414. # unknown恢复流程:等1秒二次截图确认 → 3次确认unknown → back一层+重新点击进入(刷新)→ 再检测 → 仍unknown才重启
  1415. unknown_round = 0
  1416. backed_once = False
  1417. for page_retry in range(9):
  1418. shot_check = _pfx("page_check.png")
  1419. ex.driver.screenshot(shot_check)
  1420. check_raw = OCR.recognize(shot_check, detail="all")
  1421. # 方法A: 模板匹配检测验证码
  1422. import os as _os
  1423. captcha_tpl = str(Path(__file__).parent / "files" / "captcha1.png")
  1424. if _os.path.exists(captcha_tpl):
  1425. si = cv2.imread(shot_check)
  1426. ti = cv2.imread(captcha_tpl)
  1427. if si is not None and ti is not None:
  1428. gs = cv2.cvtColor(si, cv2.COLOR_BGR2GRAY)
  1429. gt = cv2.cvtColor(ti, cv2.COLOR_BGR2GRAY)
  1430. h_s, w_s = gs.shape
  1431. crop_y1, crop_y2 = int(h_s * 0.25), int(h_s * 0.75)
  1432. crop_x1, crop_x2 = 0, 400
  1433. gs_crop = gs[crop_y1:crop_y2, crop_x1:crop_x2]
  1434. scores = []
  1435. for fn, ss, tt in [
  1436. ("gray", gs_crop, gt),
  1437. ("edge", cv2.Canny(gs_crop,30,100), cv2.Canny(gt,30,100)),
  1438. ("hist", cv2.equalizeHist(gs_crop), cv2.equalizeHist(gt)),
  1439. ("blur", cv2.GaussianBlur(gs_crop,(3,3),0), cv2.GaussianBlur(gt,(3,3),0)),
  1440. ("otsu", cv2.threshold(gs_crop,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)[1],
  1441. cv2.threshold(gt,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)[1]),
  1442. ]:
  1443. if ss.ndim == 2 and tt.ndim == 2 and ss.shape[0] >= tt.shape[0] and ss.shape[1] >= tt.shape[1]:
  1444. r = cv2.matchTemplate(ss, tt, cv2.TM_CCOEFF_NORMED)
  1445. _, mv, _, _ = cv2.minMaxLoc(r)
  1446. scores.append((mv, fn))
  1447. if scores:
  1448. best_v = max(s[0] for s in scores)
  1449. best_m = max(scores, key=lambda s: s[0])[1]
  1450. print(f" 验证码模板匹配: {best_m}={best_v:.3f}")
  1451. captcha_kw = any("拖动滑块" in r["text"] or "请按住滑块" in r["text"] or "安全验证" in r["text"] for r in check_raw)
  1452. nine_kw = any("提交" in r["text"] or "没有新图片" in r["text"] for r in check_raw)
  1453. if best_v >= 0.30 and (captcha_kw or nine_kw):
  1454. print(f" ⚠ 检测到验证码,尝试自动处理...")
  1455. if _handle_captcha(ex, [r["text"] for r in check_raw]):
  1456. continue
  1457. return "__TERMINATE__"
  1458. elif best_v >= 0.30 and not captcha_kw:
  1459. print(f" ⚠ 模板匹配命中但OCR无验证码关键词,忽略")
  1460. page_type = AIParser().check_page(check_raw)
  1461. ptype = page_type.get("type", "unknown")
  1462. if ptype == "risk":
  1463. # OCR文字二次确认:店铺页/列表页文字多但无验证码特征词 → 误判,忽略
  1464. if not _has_captcha_kw([r["text"] for r in check_raw]):
  1465. print(" AI判risk但OCR无验证码特征词,忽略(店铺页/列表页误判)")
  1466. continue
  1467. print(f" ⚠ AI检测到验证码,尝试自动处理...")
  1468. if _handle_captcha(ex, [r["text"] for r in check_raw]):
  1469. continue
  1470. return "__TERMINATE__"
  1471. if ptype == "home":
  1472. # 二次确认:截图太早页面没加载完时AI可能误判首页(底部导航"我的"在任何页面都可见)
  1473. time.sleep(1)
  1474. ex.driver.screenshot(shot_check)
  1475. confirm_raw = OCR.recognize(shot_check, detail="all")
  1476. ptype2 = AIParser().check_page(confirm_raw).get("type", "unknown")
  1477. if ptype2 == "home":
  1478. print(" ⚠ 二次确认仍为首页(被踢回/退过头),触发重启恢复")
  1479. return "__RESTART__"
  1480. if ptype2 == "normal":
  1481. break # 页面加载完成,恢复正常
  1482. print(f" AI先判home,二次确认为{ptype2},继续检测")
  1483. continue
  1484. if ptype == "list":
  1485. print(" ⚠ AI检测到列表页(未成功进店),放弃本店")
  1486. return ""
  1487. if ptype == "login":
  1488. global ACCOUNT_ABORTED
  1489. ACCOUNT_ABORTED = True
  1490. print(" ⚠ AI检测到登录页(账号被踢/封号),停止采集")
  1491. return "__TERMINATE__"
  1492. if ptype == "normal":
  1493. break # 正常,跳出重试循环
  1494. # qrcode/unknown:等1秒二次截图确认(点击后立即截图可能页面没加载完,避免误判)
  1495. time.sleep(1)
  1496. ex.driver.screenshot(shot_check)
  1497. confirm_raw = OCR.recognize(shot_check, detail="all")
  1498. ptype2 = AIParser().check_page(confirm_raw).get("type", "unknown")
  1499. if ptype2 == "normal":
  1500. break
  1501. if ptype2 == "risk":
  1502. if not _has_captcha_kw([r["text"] for r in confirm_raw]):
  1503. print(" 二次确认risk但OCR无验证码特征词,忽略")
  1504. continue
  1505. if _handle_captcha(ex, [r["text"] for r in confirm_raw]):
  1506. continue
  1507. return "__TERMINATE__"
  1508. if ptype2 == "home":
  1509. print(" ⚠ 二次确认检测到首页,触发重启恢复")
  1510. return "__RESTART__"
  1511. if ptype2 == "list":
  1512. print(" ⚠ 二次确认检测到列表页,放弃本店")
  1513. return ""
  1514. if ptype2 == "login":
  1515. ACCOUNT_ABORTED = True # 本函数已声明global
  1516. print(" ⚠ 二次确认检测到登录页(账号被踢/封号),停止采集")
  1517. return "__TERMINATE__"
  1518. # 两次都是unknown → 确认计数
  1519. unknown_round += 1
  1520. print(f" 确认unknown(第{unknown_round}次)")
  1521. if unknown_round >= 3:
  1522. if not backed_once:
  1523. # 3次确认unknown:back一层 + 重新点击进入一次(类似刷新)
  1524. print(" 3次确认unknown,back一层并重新点击进入")
  1525. ex.driver.press("back")
  1526. time.sleep(1.5)
  1527. if shop_xy:
  1528. ex.tap(shop_xy[0], shop_xy[1])
  1529. time.sleep(2.5)
  1530. backed_once = True
  1531. unknown_round = 0
  1532. else:
  1533. # 刷新后仍unknown → 重启恢复
  1534. import shutil
  1535. err_dir = SCREENSHOT_DIR / ex.device_id / "step4" / "unrecognized"
  1536. err_dir.mkdir(exist_ok=True)
  1537. shutil.copy(shot_check, str(err_dir / f"unknown_{int(time.time())}.png"))
  1538. print(" ⚠ 刷新后仍unknown,触发重启恢复")
  1539. return "__RESTART__"
  1540. time.sleep(2)
  1541. else:
  1542. # 检测循环耗尽仍未正常 → 重启恢复
  1543. import shutil
  1544. err_dir = SCREENSHOT_DIR / ex.device_id / "step4" / "unrecognized"
  1545. err_dir.mkdir(exist_ok=True)
  1546. shutil.copy(shot_check, str(err_dir / f"unknown_{int(time.time())}.png"))
  1547. print(f" ⚠ 检测循环耗尽({ptype}),触发重启恢复")
  1548. return "__RESTART__"
  1549. # normal → 继续
  1550. # ── 店铺页判断:OCR同时检测到「刚刚搜过」和「评价」说明在店铺页 ──
  1551. in_shop = False
  1552. for _ in range(10):
  1553. shop_check = _pfx("shop_check.png")
  1554. ex.driver.screenshot(shop_check)
  1555. shop_raw = OCR.recognize(shop_check, detail="text")
  1556. has_ganggang = any("刚刚搜过" in t for t in shop_raw)
  1557. has_pingjia = any("评价" in t for t in shop_raw)
  1558. if has_ganggang and has_pingjia:
  1559. in_shop = True
  1560. print(f" 已确认在店铺页")
  1561. break
  1562. time.sleep(1)
  1563. if not in_shop:
  1564. print(f" ⚠ 未检测到店铺页,继续尝试...")
  1565. # ── 第1步:截图 + AI找商品标题坐标(AI失败或返回None时重试3次,页面可能未加载完)──
  1566. system_prompt = """你收到店铺页的OCR文字。商品标题文字坐标已知(从OCR中有x,y)。
  1567. 请找到和以下商品标题匹配的文字块,返回其点击坐标。
  1568. 【重要规则】
  1569. - 坐标必须从OCR数据中选取,不得编造或估算
  1570. - 如果找不到完全匹配的,找最相似的
  1571. - 如果完全找不到,返回null
  1572. 只返回JSON:
  1573. {"title_xy": [x, y] 或 null, "shop": "店铺名"}"""
  1574. parser = AIParser()
  1575. title_xy = None
  1576. for ai_try in range(3):
  1577. shot = _pfx("shop.png")
  1578. ex.driver.screenshot(shot)
  1579. raw = OCR.recognize(shot, detail="all")
  1580. sorted_r = sorted(raw, key=lambda r: r["bbox"][0][1])
  1581. lines = []
  1582. for r in sorted_r:
  1583. cx = (r["bbox"][0][0] + r["bbox"][2][0]) // 2
  1584. cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2
  1585. lines.append(f"[x={cx:4d}, y={cy:4d}] {r['text']}")
  1586. ocr_text = "\n".join(lines)
  1587. resp = parser._call(system_prompt, f"商品标题: {product_title}\n\nOCR文字:\n{ocr_text}\n\n请返回商品标题坐标。")
  1588. import json
  1589. cleaned = resp.strip()
  1590. if cleaned.startswith("```"):
  1591. cl = cleaned.split("\n")
  1592. if cl[0].startswith("```"): cl = cl[1:]
  1593. if cl and cl[-1].strip() == "```": cl = cl[:-1]
  1594. cleaned = "\n".join(cl).strip()
  1595. try:
  1596. data = json.loads(cleaned)
  1597. title_xy = data.get("title_xy")
  1598. except json.JSONDecodeError:
  1599. title_xy = None
  1600. if title_xy and isinstance(title_xy, list) and len(title_xy) == 2:
  1601. break
  1602. print(f" ⚠ AI未返回有效坐标(第{ai_try+1}次): {title_xy},2秒后重试")
  1603. time.sleep(2)
  1604. if not title_xy or not isinstance(title_xy, list) or len(title_xy) != 2:
  1605. print(f" ⚠ AI 3次均未返回有效坐标: {title_xy}")
  1606. return ""
  1607. tx, ty = title_xy
  1608. if tx is None or ty is None:
  1609. print(f" ⚠ AI返回空坐标")
  1610. return ""
  1611. tx, ty = int(tx), int(ty)
  1612. w, h = ex.driver.window_size()
  1613. if not (0 <= tx <= w and 0 <= ty <= h):
  1614. print(f" ⚠ 坐标越界: ({tx},{ty}) 超出屏幕 {w}x{h}")
  1615. return ""
  1616. # ── 第2步:点击商品标题 → 进入商品详情(最多重试3次)──
  1617. entered_detail = False
  1618. for attempt in range(3):
  1619. print(f" 点击商品标题: ({tx},{ty}) (第{attempt+1}次)")
  1620. ex.tap(tx, ty)
  1621. time.sleep(3) # 点击后先等页面响应(验证码处理后/网络慢时切换慢)
  1622. # 检测是否进入商品详情页(验证码检测优先——验证码页文字块少,不能被"加载中"分支挡掉)
  1623. for _ in range(8):
  1624. time.sleep(2)
  1625. detail_check = _pfx("detail_check.png")
  1626. ex.driver.screenshot(detail_check)
  1627. detail_raw = OCR.recognize(detail_check, detail="all")
  1628. detail_texts = [r["text"] for r in detail_raw]
  1629. # 1. 验证码优先检测:验证码页文字块少(可能≤8),必须先于"加载中"判断
  1630. captcha_kw = any("拖动滑块" in t or "请按住滑块" in t or "安全验证" in t for t in detail_texts)
  1631. if captcha_kw:
  1632. print(f" ⚠ 检测到验证码页面,尝试自动处理...")
  1633. if _handle_captcha(ex, detail_texts):
  1634. continue
  1635. return "__TERMINATE__"
  1636. # 1.5 「重新加载」页(出错了/检修中,验证码通过后常见):点重新加载后继续等
  1637. if any("重新加载" in t for t in detail_texts):
  1638. for r in detail_raw:
  1639. if "重新加载" in r["text"]:
  1640. box = r["bbox"]
  1641. bx = (box[0][0] + box[2][0]) // 2
  1642. by = (box[0][1] + box[2][1]) // 2
  1643. print(f" 检测到「重新加载」页,点击重新加载 ({bx},{by})")
  1644. ex.tap(bx, by)
  1645. time.sleep(2)
  1646. break
  1647. continue
  1648. # 2. 页面加载中/切换中(文字少且无验证码)→ 继续等待,不误判
  1649. if len(detail_texts) <= 8:
  1650. print(f" 页面未就绪(仅{len(detail_texts)}块文字),继续等待...")
  1651. continue
  1652. # 2.5 检测是否退回首页/列表页(点标题失败/back过头时常见)→ 立即处理,不盲等
  1653. joined_texts = "".join(detail_texts)
  1654. if ("看病买药" in joined_texts) and ("我的" in joined_texts):
  1655. print(" ⚠ 检测到已退回首页,触发重启恢复")
  1656. return "__RESTART__"
  1657. if "筛选" in joined_texts:
  1658. print(" ⚠ 检测到已回列表页,放弃本店(点标题未成功进店)")
  1659. return ""
  1660. # 3. 检测商品详情页关键词
  1661. if any("加入购物车" in t or "立即购买" in t or "选规格" in t or "商品详情页" in t for t in detail_texts):
  1662. print(f" 已进入商品详情页")
  1663. entered_detail = True
  1664. break
  1665. # 4. 不在详情页,检测是否还在店铺页
  1666. has_ganggang = any("刚刚搜过" in t for t in detail_texts)
  1667. has_pingjia = any("评价" in t for t in detail_texts)
  1668. if has_ganggang and has_pingjia:
  1669. print(f" 仍在店铺页,重试...")
  1670. break # 跳出内层循环
  1671. if entered_detail:
  1672. break
  1673. # 内层循环结束仍未进入详情页:AI判断当前实际页面类型(诊断 + 验证码/首页/列表页兜底)
  1674. try:
  1675. last_shot = _pfx("detail_check.png")
  1676. if cv2.imread(last_shot) is not None:
  1677. ai_raw = OCR.recognize(last_shot, detail="all")
  1678. ai_page = AIParser().check_page(ai_raw)
  1679. print(f" AI页面类型: {ai_page.get('type')} - {ai_page.get('detail', '')[:40]}")
  1680. if ai_page.get("type") == "risk":
  1681. if not _has_captcha_kw([r["text"] for r in ai_raw]):
  1682. print(" AI判risk但OCR无验证码特征词,忽略")
  1683. else:
  1684. print(" ⚠ AI检测到验证码页,尝试自动处理...")
  1685. if _handle_captcha(ex, [r["text"] for r in ai_raw]):
  1686. continue
  1687. return "__TERMINATE__"
  1688. if ai_page.get("type") == "home":
  1689. print(" ⚠ AI检测到首页,触发重启恢复")
  1690. return "__RESTART__"
  1691. if ai_page.get("type") == "list":
  1692. print(" ⚠ AI检测到列表页,放弃本店")
  1693. return ""
  1694. if ai_page.get("type") == "login":
  1695. ACCOUNT_ABORTED = True # 本函数已声明global
  1696. print(" ⚠ AI检测到登录页(账号被踢/封号),停止采集")
  1697. return "__TERMINATE__"
  1698. except Exception as e:
  1699. print(f" ⚠ AI页面类型检测异常: {e}")
  1700. # 第1次失败后,重新OCR+AI获取坐标(可能是页面滚动导致坐标偏移)
  1701. if attempt < 2:
  1702. print(f" 重新OCR获取坐标...")
  1703. re_shot = _pfx("shop.png")
  1704. ex.driver.screenshot(re_shot)
  1705. re_raw = OCR.recognize(re_shot, detail="all")
  1706. re_sorted = sorted(re_raw, key=lambda r: r["bbox"][0][1])
  1707. re_lines = []
  1708. for r in re_sorted:
  1709. r_cx = (r["bbox"][0][0] + r["bbox"][2][0]) // 2
  1710. r_cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2
  1711. re_lines.append(f"[x={r_cx:4d}, y={r_cy:4d}] {r['text']}")
  1712. re_ocr_text = "\n".join(re_lines)
  1713. re_resp = parser._call(system_prompt, f"商品标题: {product_title}\n\nOCR文字:\n{re_ocr_text}\n\n请返回商品标题坐标。")
  1714. re_cleaned = re_resp.strip()
  1715. if re_cleaned.startswith("```"):
  1716. rl = re_cleaned.split("\n")
  1717. if rl[0].startswith("```"): rl = rl[1:]
  1718. if rl and rl[-1].strip() == "```": rl = rl[:-1]
  1719. re_cleaned = "\n".join(rl).strip()
  1720. try:
  1721. re_data = json.loads(re_cleaned)
  1722. re_xy = re_data.get("title_xy")
  1723. if re_xy and isinstance(re_xy, list) and len(re_xy) == 2 and re_xy[0] is not None:
  1724. tx, ty = int(re_xy[0]), int(re_xy[1])
  1725. ww, hh = ex.driver.window_size()
  1726. if not (0 <= tx <= ww and 0 <= ty <= hh):
  1727. print(f" ⚠ 新坐标越界: ({tx},{ty}),保持原坐标")
  1728. else:
  1729. print(f" 新坐标: ({tx},{ty})")
  1730. except Exception:
  1731. pass
  1732. else:
  1733. pass # 3次重试结束
  1734. if not entered_detail:
  1735. print(f" ⚠ 3次点击未进入商品详情页,跳过")
  1736. return ""
  1737. # ── 第3步:ORB特征匹配找分享图标 ──
  1738. share_shot = _pfx("find_share.png")
  1739. ex.driver.screenshot(share_shot)
  1740. screen = cv2.imread(share_shot)
  1741. template_path = str(Path(__file__).parent / "files" / "share.png")
  1742. template = cv2.imread(template_path)
  1743. sx, sy = None, None
  1744. if screen is not None and template is not None:
  1745. h_s, w_s = screen.shape[:2]
  1746. # 右上角区域(分享图标永远在右上)
  1747. roi_x1, roi_y1 = w_s * 2 // 3, 0
  1748. roi = screen[roi_y1:h_s // 4, roi_x1:w_s]
  1749. # 方法A: SIFT 特征匹配(限制右上角区域,减少干扰)
  1750. sx, sy = None, None
  1751. sift = cv2.SIFT_create(nfeatures=1500)
  1752. kp1, des1 = sift.detectAndCompute(template, None)
  1753. kp2, des2 = sift.detectAndCompute(roi, None)
  1754. if des1 is not None and des2 is not None and len(kp1) >= 2 and len(kp2) >= 2:
  1755. bf = cv2.BFMatcher()
  1756. matches = bf.knnMatch(des1, des2, k=2)
  1757. good = []
  1758. for m, n in matches:
  1759. if m.distance < 0.75 * n.distance:
  1760. good.append(m)
  1761. print(f" 分享SIFT(右上区域): 模板{len(kp1)}特征 ROI{len(kp2)}特征 优质{len(good)}")
  1762. if len(good) >= 4:
  1763. src_pts = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
  1764. dst_pts = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
  1765. matrix, _ = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
  1766. if matrix is not None:
  1767. h_t, w_t = template.shape[:2]
  1768. corners = np.float32([[0, 0], [w_t, 0], [w_t, h_t], [0, h_t]]).reshape(-1, 1, 2)
  1769. transformed = cv2.perspectiveTransform(corners, matrix)
  1770. sx = roi_x1 + int(np.mean(transformed[:, 0, 0]))
  1771. sy = int(np.mean(transformed[:, 0, 1]))
  1772. print(f" 分享SIFT匹配: ({sx},{sy})")
  1773. # 方法B: 多尺度模板匹配(右上角区域)
  1774. # 分辨率适配:以 720p 为基准,按屏幕宽度比例调整搜索尺度,覆盖 480p~1080p
  1775. base_w = 720.0
  1776. scale_ratio = w_s / base_w
  1777. # 模板在 720p 下约占 11% 屏宽,目标尺度应使模板覆盖相同比例
  1778. scales = [round(scale_ratio * s, 2) for s in [0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5]]
  1779. if sx is None:
  1780. best_val, best_loc, best_sw, best_sh = 0, None, 0, 0
  1781. for scale in scales:
  1782. scaled = cv2.resize(template, None, fx=scale, fy=scale)
  1783. sw, sh = scaled.shape[1], scaled.shape[0]
  1784. if sh > roi.shape[0] or sw > roi.shape[1]:
  1785. continue
  1786. res = cv2.matchTemplate(roi, scaled, cv2.TM_CCOEFF_NORMED)
  1787. _, mv, _, ml = cv2.minMaxLoc(res)
  1788. if mv > best_val:
  1789. best_val, best_loc, best_sw, best_sh = mv, ml, sw, sh
  1790. t_edge = cv2.Canny(scaled, 30, 100)
  1791. r_edge = cv2.Canny(roi, 30, 100)
  1792. if t_edge.shape[0] <= r_edge.shape[0] and t_edge.shape[1] <= r_edge.shape[1]:
  1793. res2 = cv2.matchTemplate(r_edge, t_edge, cv2.TM_CCOEFF_NORMED)
  1794. _, mv2, _, ml2 = cv2.minMaxLoc(res2)
  1795. if mv2 > best_val:
  1796. best_val, best_loc, best_sw, best_sh = mv2, ml2, sw, sh
  1797. print(f" 分享模板匹配(右上): 最佳={best_val:.3f}")
  1798. if best_val >= 0.26 and best_loc is not None:
  1799. sx = roi_x1 + best_loc[0] + best_sw // 2
  1800. sy = best_loc[1] + best_sh // 2
  1801. if sx is not None and sy is not None:
  1802. print(f" 分享图标: ({sx},{sy})")
  1803. ex.tap(sx, sy)
  1804. else:
  1805. print(f" ⚠ 未找到分享图标")
  1806. return ""
  1807. # 等弹窗出现,同时记录"分享到"y坐标用于QR裁剪
  1808. share_y = None
  1809. waimai_y = None
  1810. for _ in range(8):
  1811. time.sleep(1)
  1812. ck = _pfx("share_popup.png")
  1813. ex.driver.screenshot(ck)
  1814. detail = OCR.recognize(ck, detail="all")
  1815. texts = [r["text"] for r in detail]
  1816. if any("分享到" in t for t in texts):
  1817. print(f" 分享弹窗出现")
  1818. # 记录"分享到"和"外卖"的y坐标
  1819. for r in detail:
  1820. cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2
  1821. if "分享到" in r["text"] and share_y is None:
  1822. share_y = cy
  1823. if "外卖" in r["text"] and waimai_y is None:
  1824. waimai_y = cy
  1825. break
  1826. if share_y is None:
  1827. share_y = int(ex.driver.window_size()[1] * 0.74) # fallback
  1828. # ── 第4步:截图 → 多方法解析二维码(多次重试) ──
  1829. import os as _os_debug
  1830. _debug_dir = str(SCREENSHOT_DIR / ex.device_id / "step4" / "debug_qr")
  1831. _os_debug.makedirs(_debug_dir, exist_ok=True)
  1832. # 微信QR解码器(对ECI编码的饿了么QR鲁棒,实测成功率94%)
  1833. _wx_detector = cv2.wechat_qrcode.WeChatQRCode() if hasattr(cv2, "wechat_qrcode") else None
  1834. def _decode_qr(img, share_y, waimai_y=None):
  1835. """基于OCR定位的share_y裁剪QR区域解析"""
  1836. if img is None: return ""
  1837. h, w = img.shape[:2]
  1838. gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
  1839. detector = cv2.QRCodeDetector()
  1840. # 裁剪区域:y从"分享到"上方推算(不依赖"外卖"文字,避免OCR误判)
  1841. # QR 通常在"分享到"上方 15%~35% 屏高范围,取中间偏下
  1842. y_top = max(0, share_y - int(h * 0.30))
  1843. y_bot = share_y
  1844. x_l, x_r = int(w * 0.58), int(w * 0.94)
  1845. crop_save = img[y_top:y_bot, x_l:x_r]
  1846. cv2.imwrite(_pfx("qr_crop.png"), crop_save)
  1847. # 保存调试截图:标注裁剪区域 + QR 边界(按设备ID区分,方便对比不同分辨率)
  1848. debug_img = img.copy()
  1849. cv2.rectangle(debug_img, (x_l, y_top), (x_r, y_bot), (0, 255, 0), 3)
  1850. cv2.putText(debug_img, f"crop:({x_l},{y_top})~({x_r},{y_bot})", (x_l, y_top - 10),
  1851. cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)
  1852. _ts = int(time.time())
  1853. cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_region_{_ts}.png", debug_img)
  1854. # 方法0: 微信QR解码器(对ECI编码的饿了么QR鲁棒,实测94%成功率,全图直接解析)
  1855. if _wx_detector is not None:
  1856. try:
  1857. wx_texts, wx_points = _wx_detector.detectAndDecode(img)
  1858. if wx_texts and wx_texts[0]:
  1859. data = wx_texts[0]
  1860. if wx_points is not None and len(wx_points) > 0:
  1861. wp = wx_points[0].astype(int)
  1862. x1, y1 = wp[:, 0].min(), wp[:, 1].min()
  1863. x2, y2 = wp[:, 0].max(), wp[:, 1].max()
  1864. cv2.rectangle(debug_img, (x1, y1), (x2, y2), (0, 0, 255), 3)
  1865. cv2.putText(debug_img, "WX-QR", (x1, y1 - 10),
  1866. cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
  1867. cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img)
  1868. return data
  1869. except Exception:
  1870. pass
  1871. def _try_decode(roi_gray, zooms=(1,)):
  1872. """在灰度图上尝试多种方式解码"""
  1873. if roi_gray is None or roi_gray.size == 0 or roi_gray.shape[0] == 0 or roi_gray.shape[1] == 0:
  1874. return ""
  1875. # 高分辨率下 QR 可能太大导致 detect 失败,先缩到合理尺寸再解析
  1876. h_roi, w_roi = roi_gray.shape[:2]
  1877. if max(h_roi, w_roi) > 500:
  1878. scale_down = 500 / max(h_roi, w_roi)
  1879. roi_small = cv2.resize(roi_gray, None, fx=scale_down, fy=scale_down, interpolation=cv2.INTER_AREA)
  1880. else:
  1881. roi_small = roi_gray
  1882. for z in zooms:
  1883. if z > 1:
  1884. big = cv2.resize(roi_small, None, fx=z, fy=z, interpolation=cv2.INTER_NEAREST)
  1885. else:
  1886. big = roi_small
  1887. data, _, _ = detector.detectAndDecode(big)
  1888. if data: return data
  1889. # OTSU + zoom
  1890. for z in zooms:
  1891. _, th = cv2.threshold(roi_small, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
  1892. big = cv2.resize(th, None, fx=z, fy=z, interpolation=cv2.INTER_NEAREST) if z > 1 else th
  1893. data, _, _ = detector.detectAndDecode(big)
  1894. if data: return data
  1895. return ""
  1896. # 方法A: 全图detect定位QR → 中心 ±qr_half 精确裁剪解析(qr_half 随分辨率缩放)
  1897. # QR 在全图里 detect 可能失败(干扰太多),但先试试
  1898. ok, points = detector.detect(gray)
  1899. if ok and points is not None and len(points) > 0:
  1900. pts = points[0].astype(int)
  1901. cx = int(np.mean(pts[:, 0]))
  1902. cy = int(np.mean(pts[:, 1]))
  1903. # 根据 detect 到的 QR 边界估算大小,裁剪 QR 中心 ± qr_half
  1904. qr_half = max(int(max(np.linalg.norm(pts[0] - pts[1]), np.linalg.norm(pts[1] - pts[2])) / 2) + 20, 60)
  1905. x1, y1 = max(0, cx - qr_half), max(0, cy - qr_half)
  1906. x2, y2 = min(w, cx + qr_half), min(h, cy + qr_half)
  1907. if x2 > x1 and y2 > y1:
  1908. data = _try_decode(gray[y1:y2, x1:x2], (1, 2, 3))
  1909. if data:
  1910. # 在调试截图上标注 QR 检测位置
  1911. cv2.rectangle(debug_img, (x1, y1), (x2, y2), (0, 0, 255), 3)
  1912. cv2.putText(debug_img, f"QR:({cx},{cy})", (x1, y1 - 10),
  1913. cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
  1914. cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img)
  1915. return data
  1916. # 方法B: 用 detector.detect 在扫描区域内定位 QR → 中心 ± qr_half 精确裁剪解析
  1917. scan_area = gray[y_top:y_bot, x_l:x_r]
  1918. sh, sw = scan_area.shape
  1919. ok2, pts2 = detector.detect(scan_area)
  1920. if ok2 and pts2 is not None and len(pts2) > 0:
  1921. qr_pts = pts2[0].astype(int)
  1922. qx = int(np.mean(qr_pts[:, 0]))
  1923. qy = int(np.mean(qr_pts[:, 1]))
  1924. qr_half = max(int(max(np.linalg.norm(qr_pts[0] - qr_pts[1]), np.linalg.norm(qr_pts[1] - qr_pts[2])) / 2) + 20, 60)
  1925. qx1, qy1 = max(0, qx - qr_half), max(0, qy - qr_half)
  1926. qx2, qy2 = min(sw, qx + qr_half), min(sh, qy + qr_half)
  1927. if qx2 > qx1 and qy2 > qy1:
  1928. data = _try_decode(scan_area[qy1:qy2, qx1:qx2], (1, 2, 3))
  1929. if data:
  1930. cv2.rectangle(debug_img, (x_l + qx1, y_top + qy1), (x_l + qx2, y_top + qy2), (0, 0, 255), 3)
  1931. cv2.putText(debug_img, f"QR:({x_l + qx},{y_top + qy})", (x_l + qx1, y_top + qy1 - 10),
  1932. cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
  1933. cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img)
  1934. return data
  1935. # 方法C: 滑动窗口扫描(基于OCR定位区域,尺寸随分辨率缩放)
  1936. win = max(40, min(200, sh // 2, sw // 2))
  1937. step = max(40, win // 3)
  1938. for y in range(0, max(1, sh - win), step):
  1939. for x in range(0, max(1, sw - win), step):
  1940. patch = scan_area[y:y+win, x:x+win]
  1941. data = _try_decode(patch, (1, 2))
  1942. if data:
  1943. # 在调试截图上标注命中的窗口位置
  1944. cv2.rectangle(debug_img, (x_l + x, y_top + y), (x_l + x + win, y_top + y + win), (0, 0, 255), 3)
  1945. cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img)
  1946. return data
  1947. # 方法D: 全图 OTSU + detect → 中心 ± qr_half 精确裁剪解析
  1948. _, full_th = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
  1949. ok3, pts3 = detector.detect(full_th)
  1950. if ok3 and pts3 is not None and len(pts3) > 0:
  1951. qr_pts = pts3[0].astype(int)
  1952. qx = int(np.mean(qr_pts[:, 0]))
  1953. qy = int(np.mean(qr_pts[:, 1]))
  1954. qr_half = max(int(max(np.linalg.norm(qr_pts[0] - qr_pts[1]), np.linalg.norm(qr_pts[1] - qr_pts[2])) / 2) + 20, 60)
  1955. qx1, qy1 = max(0, qx - qr_half), max(0, qy - qr_half)
  1956. qx2, qy2 = min(w, qx + qr_half), min(h, qy + qr_half)
  1957. if qx2 > qx1 and qy2 > qy1:
  1958. data = _try_decode(gray[qy1:qy2, qx1:qx2], (1, 2, 3))
  1959. if data:
  1960. cv2.rectangle(debug_img, (qx1, qy1), (qx2, qy2), (0, 0, 255), 3)
  1961. cv2.putText(debug_img, f"QR:({qx},{qy})", (qx1, qy1 - 10),
  1962. cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
  1963. cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img)
  1964. return data
  1965. return ""
  1966. for retry in range(6): # 最多等 5+2*5=15秒
  1967. time.sleep(5 if retry == 0 else 2)
  1968. qr_shot = _pfx("qr.png")
  1969. ex.driver.screenshot(qr_shot)
  1970. data = _decode_qr(cv2.imread(qr_shot), share_y, waimai_y)
  1971. if data:
  1972. print(f" QR链接: {data[:100]}")
  1973. return data
  1974. return ""
  1975. # ── 步骤 3:滑动 + 逐个点击店铺 ──────────────────────
  1976. def _progress_file_path(device_id: str, keyword: str) -> str:
  1977. """进度文件路径(美团同款:ycwj/{设备}_{药品}.txt)"""
  1978. import hashlib
  1979. safe = hashlib.md5(keyword.encode()).hexdigest()[:8]
  1980. return str(Path(__file__).parent / "ycwj" / f"{device_id}_{safe}.txt")
  1981. def _save_progress(device_id: str, keyword: str, visited: set, scroll_px: int, batch_no: int) -> None:
  1982. """保存采集进度(每批滑动后调用,异常退出时进度已在)"""
  1983. import os
  1984. try:
  1985. path = _progress_file_path(device_id, keyword)
  1986. os.makedirs(os.path.dirname(path), exist_ok=True)
  1987. data = {
  1988. "visited": sorted(visited),
  1989. "scroll_px": scroll_px,
  1990. "batch_no": batch_no,
  1991. "time": time.strftime("%Y-%m-%d %H:%M:%S"),
  1992. }
  1993. with open(path, "w", encoding="utf-8") as f:
  1994. json.dump(data, f, ensure_ascii=False, indent=2)
  1995. except Exception as e:
  1996. print(f"[step3] 保存进度失败: {e}")
  1997. def _load_progress(device_id: str, keyword: str):
  1998. """读取采集进度,无进度文件返回 None"""
  1999. import os
  2000. try:
  2001. path = _progress_file_path(device_id, keyword)
  2002. if not os.path.exists(path):
  2003. return None
  2004. with open(path, "r", encoding="utf-8") as f:
  2005. return json.load(f)
  2006. except Exception as e:
  2007. print(f"[step3] 读取进度失败: {e}")
  2008. return None
  2009. def _delete_progress(device_id: str, keyword: str) -> None:
  2010. """任务正常完成后删除进度文件"""
  2011. import os
  2012. try:
  2013. path = _progress_file_path(device_id, keyword)
  2014. if os.path.exists(path):
  2015. os.remove(path)
  2016. print(f"[step3] 进度文件已删除: {path}")
  2017. except Exception as e:
  2018. print(f"[step3] 删除进度失败: {e}")
  2019. def _spec_ok(title: str, spec_list: list) -> bool:
  2020. """
  2021. 标题是否包含任一目标规格(美团 is_link_spec_useful 同款)。
  2022. 规格是数字+单位,OCR对数字错误率极低,用精确匹配(模糊匹配会把"10袋"误配到"10g")。
  2023. """
  2024. if not spec_list:
  2025. return True
  2026. nt = normalize_match_text(title)
  2027. return any(normalize_match_text(s) in nt for s in spec_list)
  2028. def step3_swipe_and_enter(ex: SafeExecutor, keyword: str, brand: str = "", task: dict = None,
  2029. scheduler=None) -> list:
  2030. """
  2031. 截图 → AI分析 → 逐个点击全部可见店铺 → 下滑加载更多 → 继续点击 → 直到全部遍历
  2032. 品牌+药品名过滤(美团 is_link_useful 同款):标题必须同时包含品牌名和药品名,
  2033. 否则过滤;连续30个无关商品则任务结束停止采集。
  2034. task: 调度任务 dict(task_id/enterprise_id/collect_round/current_page等,手动模式传 None)
  2035. scheduler: 调度器(逐页回告进度,手动模式传 None)
  2036. """
  2037. print("\n" + "=" * 40)
  2038. print(" 步骤 3:遍历店铺")
  2039. print("=" * 40)
  2040. # 新任务开始:重置所有停止标志和缓存(上个任务的验证码/封号停止不能污染本任务)
  2041. global _CLOSE_BTN_CACHE, CAPTCHA_ABORTED, CAPTCHA_ABORT_REASON, ACCOUNT_ABORTED, CURRENT_PAGE
  2042. global _SCHEDULER, _TASK_ID, _CRAWLED_COUNT
  2043. _CLOSE_BTN_CACHE = None
  2044. CAPTCHA_ABORTED = False
  2045. CAPTCHA_ABORT_REASON = ""
  2046. ACCOUNT_ABORTED = False
  2047. CURRENT_PAGE = 0
  2048. _SCHEDULER = scheduler
  2049. _TASK_ID = (task or {}).get("task_id")
  2050. _CRAWLED_COUNT = 0
  2051. w, h = ex.driver.window_size()
  2052. batch_no = 0
  2053. empty_streak = 0 # 连续没有新店铺的批次数
  2054. all_results = []
  2055. unrelated = 0 # 连续无关商品计数(美团同款;>=30 停止采集)
  2056. n_brand = normalize_match_text(brand)
  2057. # 目标规格(调度任务/手动 --spec 传入,如 "120粒|60粒"),用于规格过滤+入库
  2058. spec_raw = str((task or {}).get("product_specs") or "")
  2059. spec_list = (task or {}).get("spec_list") or []
  2060. if isinstance(spec_list, str): # 兼容直接传字符串
  2061. spec_list = [s.strip() for s in re.split(r'[|、,,\n\r]+', spec_list) if s.strip()]
  2062. n_key = normalize_match_text(keyword)
  2063. stopped = False
  2064. # 恢复进度:
  2065. # 1. 跨设备接力:task 带 current_page(调度重派时给)→ 按页码滑动恢复(每页=0.7屏高,跨分辨率一致)
  2066. # 2. 同设备异常恢复:本地进度文件(visited + 滑动px,精确恢复)
  2067. start_page = int((task or {}).get("current_page") or 0)
  2068. progress = _load_progress(ex.device_id, keyword)
  2069. visited = set()
  2070. total_scroll_px = 0
  2071. if start_page > 0:
  2072. print(f"[step3] 跨设备接力恢复: 调度页码={start_page},滑动{start_page}页...")
  2073. for i in range(start_page):
  2074. _adb_swipe_up(ex, int(h * 0.7))
  2075. time.sleep(2)
  2076. elif progress:
  2077. visited = set(progress.get("visited") or [])
  2078. total_scroll_px = int(progress.get("scroll_px") or 0)
  2079. print(f"[step3] 恢复进度: 已访问{len(visited)}个店铺,需滑动恢复{total_scroll_px}px")
  2080. remain = total_scroll_px
  2081. while remain > 0:
  2082. step = min(remain, int(h * 0.5))
  2083. _adb_swipe_up(ex, step)
  2084. remain -= step
  2085. time.sleep(2)
  2086. else:
  2087. visited = set()
  2088. MAX_RESTART = 3 # unknown页面异常时最大重启恢复次数(超过则终止任务)
  2089. restart_count = 0
  2090. while True:
  2091. if CAPTCHA_ABORTED:
  2092. print(f"[step3] {CAPTCHA_ABORT_REASON or chr(39)+chr(39)}停止采集")
  2093. break
  2094. _check_account_kicked(ex) # 每批检测账号是否被踢/封号(xpath检查,开销小)
  2095. if ACCOUNT_ABORTED:
  2096. print("[step3] 账号被踢/封号,停止采集")
  2097. break
  2098. named, raw_local = _get_named_shops(ex, f"step3_b{batch_no}.png", keyword)
  2099. if _ai_check_captcha(ex): # 列表页每批检测验证码(风控弹窗可能出现在列表)
  2100. time.sleep(1)
  2101. named, raw_local = _get_named_shops(ex, f"step3_b{batch_no}.png", keyword) # 验证码处理后重新识别
  2102. raw_new = [s for s in named if _shop_key(s) not in visited]
  2103. # 品牌+药品名+规格过滤(测试OCR准确率时可关闭:ENABLE_TITLE_FILTER=False 直接采集全部结果)
  2104. if not ENABLE_TITLE_FILTER:
  2105. # 测试模式:不过滤,直接采集OCR识别到的全部结果
  2106. new_ones = list(raw_new)
  2107. else:
  2108. cloud_cache = {"done": False, "texts": None}
  2109. shot_path = _shot_path(ex, f"step3_b{batch_no}.png")
  2110. new_ones = []
  2111. for s in raw_new:
  2112. # 裁剪确认用标题块自己的坐标(卡片中心裁剪会漏掉标题行)
  2113. title_y = _find_title_y(raw_local, str(s[1] or ""))
  2114. if title_y is None and len(s) > 3 and s[3]:
  2115. title_y = int(s[3][1])
  2116. v, v_reason = _match_verify(str(s[1] or ""), n_brand, n_key, shot_path, cloud_cache,
  2117. card_y=title_y)
  2118. if v in ("ok", "fuzzy"):
  2119. # 规格过滤(美团 is_link_spec_useful 同款):标题需包含任一目标规格
  2120. if spec_list and not _spec_ok(str(s[1] or ""), spec_list):
  2121. unrelated += 1
  2122. print(f"[step3] 过滤: {str(s[1])[:30]} 不含目标规格{spec_list} (连续{unrelated}个无关)")
  2123. if unrelated >= 30:
  2124. print(f"[step3] 连续{unrelated}个非目标商品,任务结束停止采集")
  2125. stopped = True
  2126. break
  2127. continue
  2128. unrelated = 0
  2129. # 云端验证过:生僻字修正开关开启时才用云端文本修正标题错字(默认关闭,标题保持OCR原样)
  2130. if v == "ok" and ENABLE_RARE_CHAR_CORRECT and cloud_cache["texts"] is not None:
  2131. s = list(s)
  2132. s[1] = _correct_title_with_cloud(str(s[1]), n_brand, n_key, cloud_cache["texts"])
  2133. new_ones.append(s)
  2134. else:
  2135. unrelated += 1
  2136. print(f"[step3] 过滤: {str(s[1])[:30]} 不含目标{v_reason} (连续{unrelated}个无关)")
  2137. if unrelated >= 30:
  2138. print(f"[step3] 连续{unrelated}个非目标商品,任务结束停止采集")
  2139. stopped = True
  2140. break
  2141. if stopped:
  2142. print("[step3] 停止采集,返回已采结果")
  2143. break
  2144. print(f"[step3] 批次{batch_no}: 共{len(named)}个, 新{len(raw_new)}个, 通过过滤{len(new_ones)}个")
  2145. # 逐页回告调度:页码 = 起点页码 + 批次数(每滑一屏算一页;global已在任务开头声明)
  2146. CURRENT_PAGE = start_page + batch_no
  2147. _CRAWLED_COUNT = len(all_results)
  2148. if scheduler is not None:
  2149. try:
  2150. scheduler.post_report({
  2151. "task_id": (task or {}).get("task_id"),
  2152. "platform": scheduler.platform,
  2153. "username": scheduler.username,
  2154. "is_finished": 0,
  2155. "need_reassign": 0,
  2156. "current_page": start_page + batch_no,
  2157. "crawled_count": len(all_results),
  2158. })
  2159. except Exception as e:
  2160. print(f"[step3] 逐页回告失败: {e}")
  2161. if not new_ones:
  2162. empty_streak += 1
  2163. print(f"[step3] 无新店铺 (连续{empty_streak}/3)")
  2164. if empty_streak >= 3:
  2165. print(f"[step3] 连续3批无新店铺,结束")
  2166. break
  2167. # 滑动后再试
  2168. print(f"[step3] 滑动查看下一批")
  2169. if len(named) >= 2:
  2170. target_y = named[-2][4]
  2171. swipe_dist = max(target_y - int(h * 0.15), int(h * 0.15))
  2172. else:
  2173. swipe_dist = int(h * 0.3)
  2174. _adb_swipe_up(ex, swipe_dist)
  2175. total_scroll_px += swipe_dist
  2176. time.sleep(2)
  2177. batch_no += 1
  2178. _save_progress(ex.device_id, keyword, visited, total_scroll_px, batch_no)
  2179. continue
  2180. empty_streak = 0 # 有新店铺,重置计数
  2181. restart_requested = False
  2182. for shop in new_ones:
  2183. result = _visit_shop(ex, shop, visited, keyword, task)
  2184. if result and result.get("__terminate__"):
  2185. print("[step3] 收到终止信号,停止遍历")
  2186. all_results = [r for r in all_results if not r.get("__terminate__")]
  2187. stopped = True
  2188. break
  2189. if result and result.get("__restart__"):
  2190. restart_requested = True
  2191. break
  2192. if result:
  2193. result["brand"] = brand
  2194. result["product_specs"] = spec_raw
  2195. all_results.append(result)
  2196. save_record(result) # 每采完一个立即入库,中断不丢数据
  2197. if restart_requested:
  2198. # unknown页面异常:重启App恢复(进度按页保存,visited保留在内存)
  2199. restart_count += 1
  2200. if restart_count >= MAX_RESTART:
  2201. print(f"[step3] 重启恢复超限({MAX_RESTART}次),终止任务")
  2202. stopped = True
  2203. break
  2204. print(f"[step3] unknown页面异常,重启App恢复(第{restart_count}/{MAX_RESTART}次)")
  2205. _save_progress(ex.device_id, keyword, visited, total_scroll_px, batch_no)
  2206. step1_open_app(ex)
  2207. step2_search(ex, (brand + keyword).strip() or keyword)
  2208. # 从列表顶部滑动恢复到上次位置
  2209. remain = total_scroll_px
  2210. while remain > 0:
  2211. step = min(remain, int(h * 0.5))
  2212. _adb_swipe_up(ex, step)
  2213. remain -= step
  2214. time.sleep(2)
  2215. continue
  2216. if stopped:
  2217. break
  2218. # 正常:滑动到倒数第二个卡片的配送距离位置,进入下一批
  2219. print(f"[step3] 已访问 {len(visited)} 个,滑动查看下一批")
  2220. if len(named) >= 2:
  2221. target_y = named[-2][4] # 倒数第二个卡片的配送距离y坐标
  2222. swipe_dist = max(target_y - int(h * 0.15), int(h * 0.15))
  2223. else:
  2224. swipe_dist = int(h * 0.3)
  2225. _adb_swipe_up(ex, swipe_dist)
  2226. total_scroll_px += swipe_dist
  2227. time.sleep(2)
  2228. batch_no += 1
  2229. _save_progress(ex.device_id, keyword, visited, total_scroll_px, batch_no) # 每页保存一次进度
  2230. continue
  2231. # 任务结束:只有正常完成才删除进度文件;验证码/封号等异常停止时保留(续采位置不丢)
  2232. if not CAPTCHA_ABORTED and not ACCOUNT_ABORTED:
  2233. _delete_progress(ex.device_id, keyword)
  2234. else:
  2235. print(f"[step3] 异常停止,保留进度文件以便恢复: {_progress_file_path(ex.device_id, keyword)}")
  2236. # ── 输出最终结果表 ──
  2237. print("\n" + "=" * 70)
  2238. print(f" 最终结果 ({len(all_results)} 个店铺)")
  2239. print("=" * 70)
  2240. for i, r in enumerate(all_results, 1):
  2241. link_short = r["link"][:55] + "..." if len(r["link"]) > 55 else r["link"]
  2242. print(f" [{i}] {r['shop']}")
  2243. print(f" 商品: {r['title'][:40]}")
  2244. print(f" 价格: {r['price']}")
  2245. print(f" 月售: {r.get('sales', '')}")
  2246. print(f" 批准文号: {r.get('approval_no', '')}")
  2247. print(f" 有效期: {r.get('validity', '')}")
  2248. print(f" 资质编号: {r.get('license_no', '')}")
  2249. lic = r.get("license") or {}
  2250. print(f" 执照: {lic.get('单位名称', '')} 信用代码:{lic.get('社会信用代码', '')} 法人:{lic.get('法人', '')}")
  2251. print(f" 执照地址: {lic.get('地址', '')[:40]}")
  2252. print(f" 链接: {link_short}")
  2253. print()
  2254. return all_results
  2255. # ── 步骤 4:(保留,当前为空 ──────────────────────────
  2256. def step4_empty(ex: SafeExecutor):
  2257. """占位,供后续扩展"""
  2258. pass
  2259. # ── 主入口 ──────────────────────────────────────────────
  2260. if __name__ == "__main__":
  2261. # 启动时自动清理:debug_qr 调试图只保留1天
  2262. try:
  2263. for p in SCREENSHOT_DIR.glob("*/step4/debug_qr/*.png"):
  2264. if time.time() - p.stat().st_mtime > 86400:
  2265. p.unlink(missing_ok=True)
  2266. except Exception:
  2267. pass
  2268. args = sys.argv[1:]
  2269. device_id = "RG5LFYT8UKK7BI95"
  2270. brand = "三九胃泰"
  2271. keyword = "养胃舒颗粒"
  2272. spec_raw = ""
  2273. spec_list = []
  2274. # 解析 --device / --brand / --keyword / --spec 参数(品牌和药品名分开传,对接调度系统)
  2275. filtered = []
  2276. i = 0
  2277. while i < len(args):
  2278. if args[i] == "--device" and i + 1 < len(args):
  2279. device_id = args[i + 1]
  2280. i += 2
  2281. elif args[i] == "--brand" and i + 1 < len(args):
  2282. brand = args[i + 1]
  2283. i += 2
  2284. elif args[i] == "--keyword" and i + 1 < len(args):
  2285. keyword = args[i + 1]
  2286. i += 2
  2287. elif args[i] == "--spec" and i + 1 < len(args):
  2288. spec_raw = args[i + 1]
  2289. spec_list = [s.strip() for s in re.split(r'[|、,,\n\r]+', spec_raw) if s.strip()]
  2290. i += 2
  2291. else:
  2292. filtered.append(args[i])
  2293. i += 1
  2294. cmd = filtered[0] if filtered else "all"
  2295. if not keyword:
  2296. keyword = filtered[1] if len(filtered) > 1 else "矿泉水"
  2297. # 搜索词 = 品牌+药品名+规格 合起来(美团同款:分开配置,搜索时合并)
  2298. search_key = (brand + keyword + spec_raw).strip() or keyword
  2299. print(f"品牌: {brand or '(无)'} | 药品名: {keyword} | 规格: {spec_list or '(不限)'} | 搜索词: {search_key}")
  2300. print("设备连接中...")
  2301. device_id = _find_device(device_id)
  2302. print(f"设备: {device_id}")
  2303. ex = SafeExecutor(device_id)
  2304. if cmd in ("all", "step1"):
  2305. ok = step1_open_app(ex)
  2306. if not ok:
  2307. sys.exit(1)
  2308. if cmd in ("all", "step2"):
  2309. ok = step2_search(ex, search_key)
  2310. if not ok:
  2311. sys.exit(1)
  2312. if cmd in ("all", "step3"):
  2313. task = {"spec_list": spec_list, "product_specs": spec_raw}
  2314. visited = step3_swipe_and_enter(ex, keyword, brand, task)
  2315. print(f"\n最终访问: {visited}")
  2316. sys.exit(0)
  2317. if cmd in ("all", "step4"):
  2318. # step4 需要先跑完 step3 获取所有商品标题,单独跑时需要手动传标题
  2319. title = keyword
  2320. link = step4_parse_qr(ex, title)
  2321. print(f"\n链接: {link}")
  2322. sys.exit(0)
  2323. sys.exit(0)