| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594 |
- """
- 饿了么闪购 — 主入口 V1
- 步骤驱动,按步执行
- """
- import sys
- import time
- import re
- import json
- import cv2
- import numpy as np
- from pathlib import Path
- from typing import Optional
- sys.path.insert(0, str(Path(__file__).parent / "steps"))
- from ocr import OCR
- from executor import SafeExecutor
- from ai_helper1 import AIParser, parse_instructions, extract_value_after, detect_popup
- from ai_helper_vision import VisionParser
- from db import save_record, get_existing_license
- from snapshot import collect_snapshot
- # ── 配置 ────────────────────────────────────────────────
- APP_PACKAGE = "me.ele"
- SCREENSHOT_DIR = Path(__file__).parent / "screenshots"
- # 生僻字标题修正开关:
- # True = 用云端文本修正标题中的生僻字漏字(如"理王"→"理洫王")
- # False = 标题保持OCR原样(默认,更安全——避免形近字误修正,如"温胃舒"被改成"养胃舒"导致错误采集)
- ENABLE_RARE_CHAR_CORRECT = True
- # 标题过滤开关:
- # True = 按品牌/药品名/规格过滤,只采目标商品(正常模式)
- # False = 不过滤,直接采集OCR识别的全部结果入库(测试OCR准确率用)
- ENABLE_TITLE_FILTER = False
- # 视觉识别开关:
- # True = 截图直接送视觉大模型识别(方案A,生僻字更准,坐标精度依赖模型)
- # False = 本地OCR + 文本AI解析(原有方案)
- USE_VISION_PARSER = True
- # 验证码频率控制:
- # 一天内 ≥CAPTCHA_DAILY_LIMIT 次 → 立即停止采集并回告(风控可能封号)
- # 每累计 CAPTCHA_REST_EVERY 次 → 休息 CAPTCHA_REST_MINUTES 分钟(第2/4/6次休息,第8次直接停止)
- CAPTCHA_DAILY_LIMIT = 8
- CAPTCHA_REST_EVERY = 2
- CAPTCHA_REST_MINUTES = 30
- CAPTCHA_LOG_DIR = Path(__file__).parent / "logs" # 验证码日志目录(每设备独立一个文件,独立计数)
- CAPTCHA_ABORTED = False # 全局标志:验证码频繁触发停止采集(调度上报用)
- CAPTCHA_ABORT_REASON = "" # 停止原因(调度回告用)
- ACCOUNT_ABORTED = False # 全局标志:账号被踢/封号,停止采集(调度上报用)
- CURRENT_PAGE = 0 # 当前页码(逐页回告/终止回告用,调度侧存续采集进度)
- # 验证码判定截图存档目录(每次判定出现验证码时截图保存,人工确认是否误判)
- CAPTCHA_CHECK_DIR = Path(__file__).parent / "logs" / "captcha_check"
- # 休息期间进度回告用的全局上下文(step3 任务开始时设置,_captcha_rest 休息时每10分钟回告)
- _SCHEDULER = None
- _TASK_ID = None
- _CRAWLED_COUNT = 0
- OCR = OCR()
- # 说明书打叉坐标缓存(仅本任务内有效):任务中第一个商品模板匹配找到打叉后,
- # 本任务后续商品直接复用该坐标(说明书页布局固定,同设备位置不变);
- # 新任务开始时清空,重新匹配。
- _CLOSE_BTN_CACHE = None
- def _find_device(device_id: str = "") -> str:
- import subprocess
- r = subprocess.run(["adb", "devices"], capture_output=True, text=True, timeout=5)
- devices = []
- for line in r.stdout.strip().split("\n")[1:]:
- if line.strip() and "device" in line and "offline" not in line:
- s = line.split("\t")[0].strip()
- if s:
- devices.append(s)
- if not devices:
- raise RuntimeError("未找到设备")
- # 如果指定了设备ID,精确匹配
- if device_id:
- for d in devices:
- if d == device_id:
- return d
- raise RuntimeError(f"未找到指定设备: {device_id},可用设备: {devices}")
- # 只有一台直接返回
- if len(devices) == 1:
- return devices[0]
- # 多台设备:列出并让用户选择
- print(f"\n发现 {len(devices)} 台设备:")
- for i, d in enumerate(devices):
- print(f" [{i}] {d}")
- while True:
- try:
- choice = input(f"请选择设备 [0-{len(devices)-1}],回车默认第一台: ").strip()
- if choice == "":
- return devices[0]
- idx = int(choice)
- if 0 <= idx < len(devices):
- return devices[idx]
- except ValueError:
- pass
- print(f"输入无效,请输入 0-{len(devices)-1}")
- def _find_text_in_area(shot_path: str, target: str, max_y: int) -> Optional[dict]:
- results = OCR.recognize(shot_path, detail="all")
- for r in results:
- if target in r["text"]:
- y = r["bbox"][0][1]
- if y < max_y:
- cx = r["bbox"][0][0] + (r["bbox"][2][0] - r["bbox"][0][0]) // 2
- cy = y + (r["bbox"][2][1] - y) // 2
- return {"x": cx, "y": cy, "text": r["text"], "conf": r["confidence"]}
- return None
- def _shot_path(ex: SafeExecutor, name: str) -> str:
- """截图路径:按 设备ID/步骤 分类组织目录(screenshots/设备/step1/xxx.png)"""
- cat = "misc"
- for prefix, c in (("step1", "step1"), ("step2", "step2"), ("step3", "step3"),
- ("_s4_", "step4"), ("sort", "step2"), ("inst_", "instructions"),
- ("lic_", "license"), ("snap_", "snapshot"), ("ad_", "popup")):
- if name.startswith(prefix):
- cat = c
- break
- d = SCREENSHOT_DIR / ex.device_id / cat
- d.mkdir(parents=True, exist_ok=True)
- return str(d / name)
- def _screenshot(ex: SafeExecutor, name: str) -> str:
- import os
- # 按 设备ID/步骤 隔离截图(目录已含设备ID,文件名不再加设备前缀)
- path = _shot_path(ex, name)
- if os.path.exists(path):
- # 保留历史截图副本:固定文件名被 test/调试脚本引用,不能被后续运行覆盖丢失
- import shutil
- stem, ext = os.path.splitext(name)
- backup = str(Path(path).parent / f"{stem}_{int(time.time() * 1000)}{ext}")
- try:
- shutil.copy2(path, backup)
- except Exception:
- pass
- # 自动清理:每个固定文件只保留最近3份备份,超出删除最旧的
- try:
- olds = sorted(
- Path(path).parent.glob(f"{stem}_[0-9]*{ext}"),
- key=lambda p: p.stat().st_mtime, reverse=True,
- )
- for p in olds[3:]:
- p.unlink(missing_ok=True)
- except Exception:
- pass
- # 方案2:截图后验证完整性(adb 流式传输可能中断,导致 PNG 损坏)
- for _try in range(3):
- ex.driver.screenshot(path)
- if cv2.imread(path) is not None:
- break
- time.sleep(0.5)
- return path
- def _where_am_i(ex: SafeExecutor) -> str:
- """判断当前位置:list=搜索结果列表页 home=首页 other=其他(全屏OCR,区分列表页与首页防止退过头)"""
- import os as _os
- tmp = _shot_path(ex, "check_pos.png")
- ex.driver.screenshot(tmp)
- if cv2.imread(tmp) is None:
- ex.driver.screenshot(tmp)
- texts = [r["text"] for r in OCR.recognize(tmp, detail="all")]
- if any("筛选" in t for t in texts):
- return "list"
- if any("看病买药" in t for t in texts) and any("我的" in t for t in texts):
- return "home"
- return "other"
- def _is_search_page(ex: SafeExecutor) -> bool:
- """判断当前是否在搜索页面:只检测屏幕顶部20%区域内是否有「筛选」"""
- import tempfile, os
- w, h = ex.driver.window_size()
- tmp = _shot_path(ex, "check_search.png")
- ex.driver.screenshot(tmp)
- if cv2.imread(tmp) is None:
- ex.driver.screenshot(tmp)
- texts = OCR.recognize(tmp, rect=[0, 0, w, int(h * 0.2)], detail="text")
- return "筛选" in texts
- # ── 步骤 1:打开 App ────────────────────────────────────
- def step1_open_app(ex: SafeExecutor) -> bool:
- print("=" * 40)
- print(" 步骤 1:打开饿了么闪购")
- print("=" * 40)
- w, h = ex.driver.window_size()
- print(f"[step1] 屏幕尺寸: {w}x{h}")
- print(f"[step1] 关闭 {APP_PACKAGE}...")
- ex.driver.app_stop(APP_PACKAGE)
- time.sleep(2)
- print(f"[step1] 启动 {APP_PACKAGE}...")
- ex.driver.app_start(APP_PACKAGE)
- time.sleep(5)
- # 等待首页加载:底部导航「我的」出现(元素没加载完就多等重试,不一次定生死)
- for attempt in range(3):
- shot = _screenshot(ex, "step1_home.png")
- texts = OCR.recognize(shot, rect=[0, int(h * 0.88), w, h], detail="text")
- print(f"[step1] 底部识别(第{attempt+1}次): {texts}")
- if any("我的" in t for t in texts):
- print("[step1] OK - 成功进入 App")
- _close_ad_popup(ex) # 每天首启的广告弹窗
- return True
- time.sleep(3)
- print("[step1] FAIL - 多次重试未检测到「我的」")
- # 识别不到"我的"可能因为账号被踢/封号——检测登录页xpath确认(存在才是封号)
- _check_account_kicked(ex)
- return False
- # ── 步骤 2:搜索商品 ────────────────────────────────────
- def _click_low_price_sort(ex: SafeExecutor) -> bool:
- """
- 搜索结果排序:点「综合」→ 选「低价优先」(失败不阻塞,找不到就跳过)。
- 找不到「低价优先」时再点一次「综合」重试,仍找不到则关闭排序弹窗。
- """
- try:
- # 1. 找「综合」并点击(排序入口,结果页顶部)
- shot = _shot_path(ex, "sort1.png")
- ex.driver.screenshot(shot)
- btn = None
- for r in OCR.recognize(shot, detail="all"):
- if "综合" in r["text"]:
- box = r["bbox"]
- btn = {"x": (box[0][0] + box[2][0]) // 2, "y": (box[0][1] + box[2][1]) // 2}
- break
- if not btn:
- print(" ⚠ 未找到「综合」排序入口,跳过排序")
- return False
- print(f" 排序: 点击「综合」({btn['x']}, {btn['y']})")
- ex.tap(btn["x"], btn["y"])
- time.sleep(2)
- # 2. 找「低价优先」
- shot2 = _shot_path(ex, "sort2.png")
- ex.driver.screenshot(shot2)
- low = None
- for r in OCR.recognize(shot2, detail="all"):
- if "低价优先" in r["text"]:
- box = r["bbox"]
- low = {"x": (box[0][0] + box[2][0]) // 2, "y": (box[0][1] + box[2][1]) // 2}
- break
- if low:
- print(f" 排序: 点击「低价优先」({low['x']}, {low['y']})")
- ex.tap(low["x"], low["y"])
- time.sleep(3)
- return True
- # 3. 没找到:再点一次「综合」关闭排序弹窗(点击综合弹出面板,再点一次收起)
- print(" ⚠ 未找到「低价优先」,再点一次「综合」关闭排序弹窗")
- ex.tap(btn["x"], btn["y"])
- time.sleep(1.5)
- return False
- except Exception as e:
- print(f" ⚠ 排序异常: {e}")
- return False
- def step2_search(ex: SafeExecutor, keyword: str) -> bool:
- print("\n" + "=" * 40)
- print(f" 步骤 2:搜索「{keyword}」")
- print("=" * 40)
- w, h = ex.driver.window_size()
- top_th = int(h * 0.3)
- # ── 阶段0+1:点击「看病买药」→ 找「搜索」(失败重试3次,3次失败重启App重试,最多2轮)──
- btn = None
- for round_idx in range(2):
- if round_idx > 0:
- # 第2轮:重启 App 重新进入(弹窗/页面异常时清状态)
- print("[step2] 重启App重新进入...")
- if not step1_open_app(ex):
- print("[step2] FAIL - 重启App失败")
- return False
- # 阶段0:点击「看病买药」
- shot0 = _screenshot(ex, "step2_phase0.png")
- btn_med = _find_text_in_area(shot0, "看病买药", h)
- if not btn_med:
- print(f"[step2] 未找到「看病买药」(第{round_idx+1}轮)")
- continue
- print(f"[step2] 找到「看病买药」: ({btn_med['x']}, {btn_med['y']})")
- ex.tap(btn_med["x"], btn_med["y"])
- time.sleep(5)
- _close_ad_popup(ex) # 点击买药后可能出现的广告弹窗
- # 阶段1:找「搜索」(重试3次,每次关弹窗+等待)
- for attempt in range(3):
- shot = _screenshot(ex, "step2_phase1.png")
- btn = _find_text_in_area(shot, "搜索", top_th)
- if btn:
- break
- print(f"[step2] 未找到「搜索」(第{attempt+1}次),关闭弹窗后重试")
- _close_ad_popup(ex)
- time.sleep(2)
- if btn:
- break # 找到搜索,继续
- print("[step2] 3次未找到「搜索」,准备重启App重试")
- if not btn:
- print("[step2] FAIL - 多次重试+重启后仍未找到「搜索」")
- return False
- print(f"[step2] 找到「搜索」: ({btn['x']}, {btn['y']})")
- cx = btn["x"] - 300 # 搜索左边约120px
- cy = btn["y"]
- print(f"[step2] 点击搜索栏: ({cx}, {cy})")
- ex.tap(cx, cy)
- time.sleep(5)
- # 搜索页确认:「搜索」位置变化才认为进入(加载慢/弹窗遮挡/点击未生效就重试)
- moved = False
- btn2 = None
- for attempt in range(3):
- shot2 = _screenshot(ex, "step2_phase2.png")
- btn2 = _find_text_in_area(shot2, "搜索", top_th)
- if btn2 and (abs(btn2["x"] - btn["x"]) > 50 or abs(btn2["y"] - btn["y"]) > 50):
- moved = True
- print(f"[step2] 搜索页搜索: ({btn2['x']}, {btn2['y']})")
- break
- # 位置没变:先关广告弹窗 + AI验证码检测,再重新点击搜索栏(可能点击没生效或被验证码拦截)
- if _ai_check_captcha(ex):
- print(f"[step2] AI检测到验证码(第{attempt+1}次),处理完成")
- if _close_ad_popup(ex):
- print(f"[step2] 已关闭广告弹窗(第{attempt+1}次)")
- print(f"[step2] 搜索位置未变化(第{attempt+1}次),重新点击搜索栏")
- ex.tap(cx, cy)
- time.sleep(4)
- if not btn2:
- print("[step2] FAIL - 进入搜索页后找不到「搜索」")
- return False
- if not moved:
- print("[step2] FAIL - 搜索位置未改变")
- return False
- cx2 = btn2["x"] - 180 # 搜索页输入框在搜索左边约180px
- cy2 = btn2["y"]
- ex.tap(cx2, cy2)
- time.sleep(2)
- print(f"[step2] 聚焦输入框,等待2s")
- print(f"[step2] 输入关键词: {keyword}")
- ex.driver.set_input_ime(True)
- time.sleep(0.3)
- ex.driver.send_keys(keyword)
- time.sleep(1)
- ex.tap(btn2["x"], btn2["y"])
- time.sleep(3)
- # 搜索后检测列表页:先处理验证码(可能挡住列表页),再检测列表页特征(加载慢就重试)
- for attempt in range(3):
- shot3 = _screenshot(ex, "step2_result.png")
- raw3 = OCR.recognize(shot3, detail="all")
- all_texts = [r["text"] for r in raw3]
- # 1. 验证码优先:验证码弹窗会挡住列表页特征,先处理再重新检测
- # 关键词没命中时用AI再判断一次(九宫格/点击式验证码文字不在关键词里)
- if any(("拖动滑块" in t) or ("请按住滑块" in t) or ("安全验证" in t) for t in all_texts):
- print(f"[step2] 检测到列表页验证码(第{attempt+1}次),尝试处理...")
- if _handle_captcha(ex, all_texts):
- print("[step2] 验证码已解决")
- else:
- print("[step2] 验证码处理失败")
- return False
- # 验证码通过后可能出现「出错了/检修中」页,点重新加载
- if _click_reload(ex):
- print("[step2] 已点击重新加载")
- time.sleep(2)
- continue
- if _ai_check_captcha(ex):
- print(f"[step2] AI检测到验证码(第{attempt+1}次),处理完成")
- continue
- # 1.2 广告弹窗(进入列表页时也可能弹出,挡住列表页特征)
- if _close_ad_popup(ex):
- print(f"[step2] 已关闭列表页广告弹窗(第{attempt+1}次)")
- continue
- # 1.5 出错/检修页(无验证码时也可能出现):点「重新加载」后重新检测
- if any("重新加载" in t for t in all_texts):
- print(f"[step2] 检测到「出错了/检修中」页面,点击重新加载")
- _click_reload(ex)
- continue
- # 2. 列表页特征检测
- has_filter = "筛选" in all_texts
- has_express = "快递" in all_texts
- kw_found = any(keyword in t for t in all_texts)
- print(f"[step2] 有筛选: {has_filter}, 有快递: {has_express}, 关键词存在: {kw_found}")
- if has_filter or has_express:
- # 如果有「快递」则点击它
- if has_express:
- for r in raw3:
- if "快递" in r["text"]:
- bx = r["bbox"]
- cx = (bx[0][0] + bx[2][0]) // 2
- cy = (bx[0][1] + bx[2][1]) // 2
- print(f"[step2] 点击「快递」: ({cx}, {cy})")
- ex.tap(cx, cy)
- time.sleep(3)
- break
- # 点击「快递」后设置排序:综合 → 低价优先
- # _click_low_price_sort(ex)
- print("[step2] OK - 搜索成功")
- return True
- print(f"[step2] 列表页特征未出现(第{attempt+1}次),等3秒重试")
- time.sleep(3)
- print("[step2] FAIL - 搜索未成功")
- return False
- def normalize_match_text(value):
- """归一化:统一全角/半角、去除空白和零宽字符(美团同款,避免'看起来一样但匹配失败')"""
- import unicodedata
- text = "" if value is None else str(value)
- text = unicodedata.normalize("NFKC", text)
- text = re.sub(r"[\s -]+", "", text)
- return text
- def _edit_distance(a: str, b: str) -> int:
- """编辑距离(Levenshtein),用于OCR错字容错"""
- if len(a) < len(b):
- a, b = b, a
- if not b:
- return len(a)
- prev = list(range(len(b) + 1))
- for i, ca in enumerate(a, 1):
- cur = [i]
- for j, cb in enumerate(b, 1):
- cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
- prev = cur
- return prev[-1]
- def _match_fuzzy(target: str, text: str) -> bool:
- """
- 容错匹配:目标词是否在文本中(处理OCR漏字/错字1个)。
- 用于判断「疑似生僻字」——本地识别差1个字时触发云端验证。
- """
- if not target:
- return True
- if target in text:
- return True
- if len(target) >= 3:
- for i in range(len(target)): # 漏字:目标删任意1字后匹配
- if target[:i] + target[i + 1:] in text:
- return True
- for L in (len(target), len(target) + 1, len(target) - 1): # 错字:编辑距离<=1
- if L < 2:
- continue
- for i in range(len(text) - L + 1):
- if _edit_distance(target, text[i:i + L]) <= 1:
- return True
- return False
- def _correct_title_with_cloud(title: str, n_brand: str, n_key: str, cloud_texts: list) -> str:
- """
- 用云端文本修正标题中的生僻字错误(精确子串替换,不做整标题替换):
- 标题里 fuzzy 匹配到目标词的子串 → 替换为云端文本确认的准确形式。
- 只处理"漏字"场景(云端形式更长,如"理王"→"理洫王");
- 同长度的形近替换(如"温胃舒"→"养胃舒")【不修正】——
- 云端文本里的目标词可能来自其他商品,误用来改标题会造成错误采集。
- """
- c_joined = "".join(cloud_texts)
- nt = normalize_match_text(title)
- for target in (n_brand, n_key):
- if not target:
- continue
- if target in nt:
- continue # 已精确匹配,无需修正
- if target not in c_joined:
- continue # 云端也没有准确形式 → 无法修正,跳过
- # 在标题里找漏字子串并替换为准确形式。
- # 只处理"目标词比子串长且编辑距离≤1"(漏字:理王→理洫王)——
- # 同长度形近替换(温胃舒→养胃舒)不触发:云端文本里的目标词可能来自其他商品,
- # 误用来改标题会造成错误采集。
- for i in range(len(nt)):
- for L in (len(target) - 1, len(target), len(target) + 1):
- if L < 2 or i + L > len(nt):
- continue
- sub = nt[i:i + L]
- if len(target) > len(sub) and _edit_distance(target, sub) <= 1:
- nt = nt[:i] + target + nt[i + L:]
- break
- else:
- continue
- break
- return nt
- def _is_cjk(ch: str) -> bool:
- """是否中文字符"""
- return '一' <= ch <= '鿿'
- def _is_missing_char(target: str, title_text: str) -> bool:
- """
- 判断目标词是否在标题里以"漏1字"形式出现(生僻字特征):
- 目标词删掉1个字后的子串,在标题里是【独立词】(前后不是汉字)。
- 例:理洫王删"洫"="理王",标题"[理王]"里独立 → 漏字 ✓
- 例:养胃舒删"舒"="养胃",标题"滋阴养胃"里嵌在词中(前有"滋")→ 非独立 → 不算漏字 ✗
- """
- for k in range(len(target)):
- sub = target[:k] + target[k + 1:]
- if len(sub) < 2:
- continue
- idx = title_text.find(sub)
- while idx != -1:
- before_ok = idx == 0 or not _is_cjk(title_text[idx - 1])
- after_ok = idx + len(sub) >= len(title_text) or not _is_cjk(title_text[idx + len(sub)])
- if before_ok and after_ok:
- return True
- idx = title_text.find(sub, idx + 1)
- return False
- def _ensure_cloud(cloud_cache: dict, shot_path: str) -> None:
- """触发一次云端OCR(每批只调一次),保存 (文本, y) 带坐标的块列表"""
- if cloud_cache["done"]:
- return
- cloud_cache["done"] = True
- try:
- raw_c = OCR.recognize(shot_path, detail="all", engine="cloud")
- if not raw_c:
- # 云端返回空(配额用尽/限流时百度返回错误码但不抛异常)→ 视为云端不可用
- print("[step3] 云端返回空结果(可能配额不足/限流),按容错处理")
- cloud_cache["blocks"] = None
- cloud_cache["texts"] = None
- return
- cloud_cache["blocks"] = [
- (normalize_match_text(r["text"]), (r["bbox"][0][1] + r["bbox"][2][1]) // 2)
- for r in raw_c
- ]
- cloud_cache["texts"] = [t for t, _ in cloud_cache["blocks"]]
- print(f"[step3] 云端二次确认({len(cloud_cache['blocks'])}块)")
- except Exception as e:
- print(f"[step3] 云端识别失败({e})")
- cloud_cache["blocks"] = None
- cloud_cache["texts"] = None
- def _cloud_region_text(cloud_cache: dict, card_y: int, max_dy: int = 300) -> Optional[str]:
- """
- 取卡片 y±max_dy 内的所有云端块合并文本(标题块可能被拆成多块,只取最近一块会漏)。
- 阈值300px:排除相邻卡片的目标词(温胃舒 y=867 vs 养胃舒 y=1305 差438 > 300 ✓)
- """
- blocks = cloud_cache.get("blocks")
- if not blocks:
- return None
- parts = [t for t, cy in blocks if abs(cy - card_y) <= max_dy]
- return "".join(parts) if parts else None
- def _cloud_crop_confirm(shot_path: str, card_y: int) -> Optional[str]:
- """
- 用本地OCR的标题块坐标裁剪标题区域(标题y-20 ~ y+80,右列),放大2倍后云端识别。
- 裁剪区只含当前商品的标题 → 云端不需要返回坐标,标准版(无配额问题)即可用,且字放大识别更准。
- """
- try:
- img = cv2.imread(shot_path)
- if img is None:
- return None
- h, w = img.shape[:2]
- y1, y2 = max(0, card_y - 20), min(h, card_y + 80)
- x1 = int(w * 0.15)
- crop = img[y1:y2, x1:w]
- if crop.size == 0:
- return None
- big = cv2.resize(crop, None, fx=2, fy=2, interpolation=cv2.INTER_LANCZOS4)
- raw = OCR.recognize(big, detail="all", engine="cloud")
- texts = [normalize_match_text(r["text"]) for r in raw
- if any('一' <= c <= '鿿' for c in r["text"])]
- return "".join(texts) if texts else None
- except Exception as e:
- print(f" [调试] 裁剪云端确认异常: {e}")
- return None
- def _match_verify(title: str, n_brand: str, n_key: str, shot_path: str, cloud_cache: dict,
- card_y: int = None) -> tuple:
- """
- 品牌/药品名匹配(只判断品牌+药品名核心词,规格/功效文字不参与)。
- 返回 (判定, 不匹配原因):判定 "ok"/"fuzzy"/"fail",原因如 "品牌"/"药品名"/"品牌、药品名"
- - 本地精确匹配 → "ok"(免费)
- - 完全不像 → "fail"(免费,不花云端)
- - 差1字/漏字 → 用本地坐标裁剪该商品标题区域,云端识别确认:
- 裁剪区含目标词(本地认错字,如理王→理洫王/甲疏咪唑)→ "ok"
- 裁剪区不含目标词(确实不是该商品,如温胃舒vs养胃舒)→ "fail"
- - 裁剪确认不可用 → 漏字场景容错放行 "fuzzy",换字场景 "fail"
- """
- nt = normalize_match_text(title)
- brand_match = (not n_brand) or (n_brand in nt)
- key_match = (not n_key) or (n_key in nt)
- if brand_match and key_match:
- return "ok", ""
- def _unmatched_reason():
- parts = []
- if n_brand and not brand_match:
- parts.append("品牌")
- if n_key and not key_match:
- parts.append("药品名")
- return "、".join(parts) or "品牌/药品名"
- # 便宜判断:完全不像(非差1字也非漏字)→ 直接过滤,不花云端
- near_brand = (not n_brand) or _match_fuzzy(n_brand, nt) or _is_missing_char(n_brand, nt)
- near_key = (not n_key) or _match_fuzzy(n_key, nt) or _is_missing_char(n_key, nt)
- if not (near_brand and near_key):
- return "fail", _unmatched_reason()
- # 差1字/漏字 → 用本地坐标裁剪该商品标题区域,云端确认(标准版即可,无需坐标)
- if card_y is not None:
- crop_text = _cloud_crop_confirm(shot_path, card_y)
- if crop_text is not None:
- if (not n_brand or n_brand in crop_text) and (not n_key or n_key in crop_text):
- # 把裁剪确认文本交给标题修正逻辑(如"理王"→"理洫王")
- cloud_cache["texts"] = [crop_text]
- return "ok", ""
- print(f" [调试] 卡片y={card_y} 裁剪云端=[{crop_text[:40]}] 不含目标词")
- return "fail", _unmatched_reason()
- print(f" [调试] 卡片y={card_y} 裁剪云端识别失败")
- # 裁剪确认不可用 → 原逻辑:漏字(独立词)→ 整页云端兜底;换字 → 过滤
- missing = []
- if n_brand and not brand_match and _is_missing_char(n_brand, nt):
- missing.append("品牌")
- if n_key and not key_match and _is_missing_char(n_key, nt):
- missing.append("药品名")
- if not missing:
- return "fail", _unmatched_reason() # 换字 → 过滤(安全默认)
- # 漏字 → 整页云端兜底(cloud_cache 保证每批只调一次)
- _ensure_cloud(cloud_cache, shot_path)
- if cloud_cache["texts"] is None:
- return "fuzzy", ""
- c_joined = "".join(cloud_cache["texts"])
- if (not n_brand or n_brand in c_joined) and (not n_key or n_key in c_joined):
- return "ok", ""
- return "fail", _unmatched_reason()
- def _screen_state(texts: list) -> str:
- """
- 根据 OCR 文本判断屏幕状态(统一的状态识别,各步骤共用):
- detail=商品详情页 shop=店铺页 list=搜索结果列表页 unknown=其他
- """
- joined = "".join(texts)
- if any(("加入购物车" in t) or ("立即购买" in t) or ("选规格" in t) or ("加入购物袋" in t) for t in texts):
- return "detail"
- if ("刚刚搜过" in joined) and ("评价" in joined):
- return "shop"
- if "筛选" in joined:
- return "list"
- return "unknown"
- def _is_list_page(ex: SafeExecutor) -> bool:
- """当前是否在搜索结果列表页(全屏检测「筛选」,店铺页/详情页不含此词)"""
- import os as _os
- tmp = _shot_path(ex, "check_list.png")
- ex.driver.screenshot(tmp)
- if cv2.imread(tmp) is None:
- ex.driver.screenshot(tmp)
- texts = [r["text"] for r in OCR.recognize(tmp, detail="all")]
- return any("筛选" in t for t in texts)
- def _find_ad_close(shot_path: str) -> Optional[dict]:
- """
- 广告弹窗打叉按钮:二值化模板匹配,形状匹配不受颜色/背景干扰。
- 双模板双二值化(固定阈值100 + OTSU自适应,各自同阈值组合),不同弹窗打叉深浅不同,取最佳。
- 命中返回 {"x","y"}。
- """
- base_dir = Path(__file__).parent / "files"
- screen = cv2.imread(shot_path, cv2.IMREAD_GRAYSCALE)
- if screen is None:
- return None
- h_s, w_s = screen.shape[:2]
- base_w = 1220.0 # 模板裁自1220宽屏,其他分辨率按比例缩放
- scale_ratio = w_s / base_w
- scales = [round(scale_ratio * s, 2) for s in [0.8, 0.9, 1.0, 1.1, 1.2]]
- best_val, best_loc, best_sw, best_sh = 0, None, 0, 0
- # (模板文件, 截图二值化方式):同阈值组合(固定×固定、OTSU×OTSU)
- pairs = (
- ("ad_close_bin.png", lambda g: cv2.threshold(g, 100, 255, cv2.THRESH_BINARY_INV)[1]),
- ("ad_close_otsu.png", lambda g: cv2.threshold(g, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]),
- )
- for tpl_name, binarize in pairs:
- template = cv2.imread(str(base_dir / tpl_name), cv2.IMREAD_GRAYSCALE)
- if template is None:
- continue
- screen_bin = binarize(screen)
- for scale in scales:
- scaled = cv2.resize(template, None, fx=scale, fy=scale)
- sw, sh = scaled.shape[1], scaled.shape[0]
- if sh > h_s or sw > w_s:
- continue
- res = cv2.matchTemplate(screen_bin, scaled, cv2.TM_CCOEFF_NORMED)
- _, mv, _, ml = cv2.minMaxLoc(res)
- if mv > best_val:
- best_val, best_loc, best_sw, best_sh = mv, ml, sw, sh
- # 阈值0.4:二值化形状匹配值高(实测0.65+),误匹配低
- if best_val >= 0.4 and best_loc is not None:
- sx = best_loc[0] + best_sw // 2
- sy = best_loc[1] + best_sh // 2
- # 位置约束:打叉在弹窗正下方(下半屏 y>0.4h),上半屏匹配视为误报
- if sy < int(h_s * 0.4):
- return None
- return {"x": sx, "y": sy}
- return None
- def _click_reload(ex: SafeExecutor) -> bool:
- """检测「出错了/正在检修中」页面并点击「重新加载」(验证码通过后可能出现)"""
- try:
- shot = _shot_path(ex, "reload_check.png")
- ex.driver.screenshot(shot)
- if cv2.imread(shot) is None:
- ex.driver.screenshot(shot)
- btn = None
- for r in OCR.recognize(shot, detail="all"):
- if "重新加载" in r["text"]:
- box = r["bbox"]
- btn = {"x": (box[0][0] + box[2][0]) // 2, "y": (box[0][1] + box[2][1]) // 2}
- break
- if not btn:
- return False
- print(f" 点击「重新加载」: ({btn['x']}, {btn['y']})")
- ex.tap(btn["x"], btn["y"])
- time.sleep(3)
- return True
- except Exception as e:
- print(f" ⚠ 重新加载处理异常: {e}")
- return False
- def _has_popup_mask(shot_path: str) -> bool:
- """
- 弹窗遮罩检测:弹窗出现时周围被半透明遮罩变暗(暗区比例大幅上升)。
- 实测:有弹窗暗区0.18-0.33,无弹窗<0.1,阈值0.12安全区分。
- """
- img = cv2.imread(shot_path, cv2.IMREAD_GRAYSCALE)
- if img is None:
- return False
- dark_ratio = float((img < 80).mean())
- return dark_ratio > 0.12
- def _close_ad_popup(ex: SafeExecutor) -> bool:
- """
- 检测并关闭广告弹窗:
- ① 遮罩检测(弹窗周围变暗,本地计算零成本,最快最准)
- ② AI 找关闭按钮(云端OCR → AI判断)
- ③ 模板匹配打叉兜底
- """
- try:
- shot = _shot_path(ex, "ad_check.png")
- ex.driver.screenshot(shot)
- if cv2.imread(shot) is None:
- ex.driver.screenshot(shot)
- # 1. 遮罩检测:无弹窗直接返回(不浪费AI调用)
- if not _has_popup_mask(shot):
- return False
- print(" 检测到弹窗(周围遮罩变暗)")
- # 2. AI 找关闭按钮(云端OCR → AI判断坐标)
- raw = OCR.recognize(shot, detail="all", engine="cloud")
- info = detect_popup(raw)
- if info["has_popup"] and info.get("close_xy") and len(info["close_xy"]) == 2:
- print(f" 弹窗检测(AI): {info['reason']}")
- print(f" 关闭广告弹窗(AI坐标): ({info['close_xy'][0]}, {info['close_xy'][1]})")
- ex.tap(int(info["close_xy"][0]), int(info["close_xy"][1]))
- time.sleep(1.5)
- return True
- # 3. 模板匹配打叉兜底(弹窗正下方居中的 ×)
- btn = _find_ad_close(shot)
- if btn:
- print(f" 关闭广告弹窗(模板): ({btn['x']}, {btn['y']})")
- ex.tap(btn["x"], btn["y"])
- time.sleep(1.5)
- return True
- print(" ⚠ 检测到弹窗但未找到关闭按钮(AI无坐标+模板未命中)")
- return True
- except Exception as e:
- print(f" ⚠ 广告弹窗处理异常: {e}")
- return False
- def _adb_swipe_up(ex: SafeExecutor, distance: int):
- """ADB 手指从下往上滑,内容下滑"""
- import subprocess
- w, h = ex.driver.window_size()
- swipe_x = w // 2
- seg = 3
- seg_px = distance // seg
- for i in range(seg):
- s = int(h * 0.8) - i * 80
- e = s - seg_px
- if e < 50:
- e = 50
- subprocess.run(
- ["adb", "-s", ex.device_id, "shell", "input", "swipe",
- str(swipe_x), str(s), str(swipe_x), str(e), "400"],
- capture_output=True, timeout=10
- )
- time.sleep(0.35)
- time.sleep(1.4)
- def _adb_swipe_left(ex: SafeExecutor, y_ratio: float = 0.3):
- """从右往左滑:切换商品图片轮播(图片在详情页上半部分)"""
- import subprocess
- w, h = ex.driver.window_size()
- y = int(h * y_ratio)
- subprocess.run(
- ["adb", "-s", ex.device_id, "shell", "input", "swipe",
- str(int(w * 0.85)), str(y), str(int(w * 0.15)), str(y), "300"],
- capture_output=True, timeout=10
- )
- time.sleep(1.2)
- def _adb_swipe_up_short(ex: SafeExecutor):
- """上滑半屏:说明书页内容可能需滑动才全部可见"""
- import subprocess
- w, h = ex.driver.window_size()
- subprocess.run(
- ["adb", "-s", ex.device_id, "shell", "input", "swipe",
- str(w // 2), str(int(h * 0.7)), str(w // 2), str(int(h * 0.3)), "300"],
- capture_output=True, timeout=10
- )
- time.sleep(1)
- def _find_close_btn(shot_path: str) -> Optional[dict]:
- """
- 说明书页右上角找打叉关闭按钮(多尺度模板匹配,适配多分辨率)。
- 模板 files/close.png,命中返回 {"x","y"},失败返回 None。
- """
- import os as _os
- tpl_path = str(Path(__file__).parent / "files" / "close.png")
- screen = cv2.imread(shot_path)
- template = cv2.imread(tpl_path)
- if screen is None or template is None:
- return None
- h_s, w_s = screen.shape[:2]
- # 右上角区域(打叉永远在右上角)
- roi_x1, roi_y1 = w_s * 2 // 3, 0
- roi = screen[roi_y1:h_s // 4, roi_x1:w_s]
- # 多尺度模板匹配(分辨率适配:以720p为基准按屏宽比例缩放)
- base_w = 720.0
- scale_ratio = w_s / base_w
- 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]]
- best_val, best_loc, best_sw, best_sh = 0, None, 0, 0
- for scale in scales:
- scaled = cv2.resize(template, None, fx=scale, fy=scale)
- sw, sh = scaled.shape[1], scaled.shape[0]
- if sh > roi.shape[0] or sw > roi.shape[1]:
- continue
- res = cv2.matchTemplate(roi, scaled, cv2.TM_CCOEFF_NORMED)
- _, mv, _, ml = cv2.minMaxLoc(res)
- if mv > best_val:
- best_val, best_loc, best_sw, best_sh = mv, ml, sw, sh
- t_edge = cv2.Canny(scaled, 30, 100)
- r_edge = cv2.Canny(roi, 30, 100)
- if t_edge.shape[0] <= r_edge.shape[0] and t_edge.shape[1] <= r_edge.shape[1]:
- res2 = cv2.matchTemplate(r_edge, t_edge, cv2.TM_CCOEFF_NORMED)
- _, mv2, _, ml2 = cv2.minMaxLoc(res2)
- if mv2 > best_val:
- best_val, best_loc, best_sw, best_sh = mv2, ml2, sw, sh
- if best_val >= 0.26 and best_loc is not None:
- sx = roi_x1 + best_loc[0] + best_sw // 2
- sy = best_loc[1] + best_sh // 2
- return {"x": sx, "y": sy}
- return None
- def _collect_instructions(ex: SafeExecutor) -> dict:
- """
- 商品详情页采集说明书(采完链接后调用):
- back关分享弹窗 → 左滑商品图片 → 点「查看详细说明」
- → 说明书页截图OCR → 提取批准文号/有效期(最多上滑3次兜底)
- 返回 {"approval_no": "", "validity": ""},任何失败留空不抛异常
- """
- w, h = ex.driver.window_size()
- pfx = f"{ex.device_id}_inst"
- # in_detail: 是否确认停留在商品详情页——主流程据此决定后续资质采集是否安全执行
- result = {"approval_no": "", "validity": "", "in_detail": False}
- # 1. 确认屏幕状态:分享弹窗→back关闭;在详情页→开始;在列表页→跳过(不再back,避免乱退)
- for _try in range(3):
- shot = _shot_path(ex, "inst_check.png")
- ex.driver.screenshot(shot)
- if cv2.imread(shot) is None:
- ex.driver.screenshot(shot)
- texts = [r["text"] for r in OCR.recognize(shot, detail="all")]
- if any("分享到" in t for t in texts):
- print(" 关闭分享弹窗")
- ex.driver.press("back")
- time.sleep(1.2)
- continue
- state = _screen_state(texts)
- if state == "detail":
- result["in_detail"] = True
- break
- if state == "list":
- print(" ⚠ 说明书采集跳过:已在列表页(step4未成功进店),不再back")
- return result
- if _try < 2:
- print(f" 屏幕状态[{state}],back一次重新确认")
- ex.driver.press("back")
- time.sleep(1.2)
- else:
- print(" ⚠ 说明书采集跳过:多次确认仍不在商品详情页")
- return result
- # 2. 左滑切换商品图片,找「查看详细说明」按钮
- _adb_swipe_left(ex, 0.3)
- shot2 = _shot_path(ex, "inst_btn.png")
- ex.driver.screenshot(shot2)
- btn = _find_text_in_area(shot2, "查看详细说明", h)
- if not btn:
- print(" ⚠ 未找到「查看详细说明」,跳过说明书采集")
- return result
- print(f" 说明书按钮: ({btn['x']}, {btn['y']})")
- ex.tap(btn["x"], btn["y"])
- time.sleep(2.5)
- _ai_check_captcha(ex) # 说明书页可能出现验证码(无关键词检查环节)
- # 3. 说明书页截图 + OCR 提取(模仿美团:批准文号和有效期都找到才停,最多滑3次)
- for attempt in range(4):
- shot3 = _shot_path(ex, "inst_page.png")
- ex.driver.screenshot(shot3)
- inst = parse_instructions(OCR.recognize(shot3, detail="all", engine="cloud"))
- if inst["approval_no"] and inst["validity"]:
- print(f" 说明书: 批准文号={inst['approval_no']} 有效期={inst['validity']}")
- result = inst
- break
- if attempt < 3:
- missing = [k for k in ("approval_no", "validity") if not inst[k]]
- print(f" 说明书字段不全(第{attempt+1}次,缺{missing}),上滑重试")
- _adb_swipe_up_short(ex)
- else:
- print(" ⚠ 说明书页4次均未解析到批准文号/有效期")
- result = inst
- # 4. 说明书采集完成:点右上角打叉关闭说明书页(不能back——back会直接回列表页)
- # 本任务内缓存:第一个商品找到后,后续商品直接复用坐标
- global _CLOSE_BTN_CACHE
- close_btn = _CLOSE_BTN_CACHE
- if close_btn is None:
- close_shot = _shot_path(ex, "inst_close.png")
- ex.driver.screenshot(close_shot)
- close_btn = _find_close_btn(close_shot)
- if close_btn:
- _CLOSE_BTN_CACHE = close_btn
- print(f" 关闭说明书页: ({close_btn['x']}, {close_btn['y']})(本任务已缓存,后续商品复用)")
- else:
- print(" ⚠ 未找到打叉按钮(后续步骤会按屏幕状态自行处理)")
- else:
- print(f" 关闭说明书页: ({close_btn['x']}, {close_btn['y']})(复用本任务缓存坐标)")
- if close_btn:
- ex.tap(close_btn["x"], close_btn["y"])
- time.sleep(1.2)
- return result
- def _collect_snapshot(ex: SafeExecutor, title: str) -> str:
- """
- 网页快照(采集说明书之后调用):
- 先识别屏幕状态——已在详情页直接拍;说明书页等未知页则back一次回详情页再拍;
- 在列表页/店铺页等明确非详情页位置直接跳过(不再back,避免把列表页退到首页)
- """
- try:
- # 1. 先截图识别状态,决定是否需要 back
- shot = _shot_path(ex, "snap_check.png")
- for _try in range(2):
- ex.driver.screenshot(shot)
- if cv2.imread(shot) is None:
- ex.driver.screenshot(shot)
- texts = [r["text"] for r in OCR.recognize(shot, detail="all")]
- state = _screen_state(texts)
- if state == "detail":
- break
- if state in ("list", "shop"):
- print(f" ⚠ 快照跳过:屏幕状态[{state}],不在详情页也不再back")
- return ""
- if _try == 0:
- # 说明书页/其他未知页:back 一次回详情页再确认
- print(f" 快照:屏幕状态[{state}],back回详情页")
- ex.driver.press("back")
- time.sleep(1.2)
- else:
- print(f" ⚠ 快照跳过:back后屏幕状态[{state}],不在详情页")
- return ""
- else:
- print(" ⚠ 快照跳过:无法确认在详情页")
- return ""
- # 2. 滚动截图 + 上传OSS(美团同款逻辑在 snapshot 模块)
- url, snap_reason = collect_snapshot(ex.driver, title, ex.device_id)
- print(f" 📷 快照: {url if url else f'失败({snap_reason})'}")
- if url:
- # 快照滚动改变了页面位置:back 回店铺页,再开始资质采集
- ex.driver.press("back")
- time.sleep(1.2)
- return url
- except Exception as e:
- print(f" ⚠ 快照采集异常: {e}")
- return ""
- def _collect_license(ex: SafeExecutor, shop_name: str) -> dict:
- """
- 采集商家资质(采完说明书后调用,屏幕在说明书页):
- back回店铺内 → OCR上半区找店铺名点击 → 下半区找「查看营业资质」点击
- → 云端OCR找「资质编号」取值 → 点编号下方约3cm打开营业执照 → 百度营业执照专用接口OCR
- 返回 {"license_no": "", "license": {}},任何失败留空不抛异常
- """
- w, h = ex.driver.window_size()
- pfx = f"{ex.device_id}_lic"
- result = {"license_no": "", "license": {}}
- # 0. 已采集过的店铺:直接从数据库获取资质,不重复采集(美团/PDD同款)
- try:
- exist = get_existing_license(shop_name)
- if exist.get("license_no") or exist.get("company"):
- print(f" 资质已存在,从数据库获取: 编号={exist['license_no'][:24]} 公司={exist['company'][:20]}")
- result["license_no"] = exist["license_no"]
- lic = {}
- if exist.get("company"):
- lic["单位名称"] = exist["company"]
- if exist.get("address"):
- lic["地址"] = exist["address"]
- result["license"] = lic
- return result
- except Exception as e:
- print(f" ⚠ 查询已有资质失败: {e}")
- # 1+2. 回到店铺页并采资质(最多2轮):每轮先找店铺名,找不到/点后无资质入口就 back 回退一层再看
- # 注意不能无条件back:屏幕已在店铺页时再退会到列表页
- def _find_license_btn():
- # 先搜下半区,找不到整张图分析(「查看营业资质」位置不固定,有时在上半区)
- for rect in ([0, int(h * 0.45), w, h], None):
- for r in OCR.recognize(shot2, rect=rect, detail="all"):
- if "查看营业资质" in r["text"]:
- box = r["bbox"]
- return {"x": (box[0][0] + box[2][0]) // 2, "y": (box[0][1] + box[2][1]) // 2}
- return None
- lic_btn = None
- for attempt in range(2):
- if _is_list_page(ex):
- print(" ⚠ 资质采集跳过:已在列表页(不点列表卡片)")
- return result
- shot = _shot_path(ex, "lic_shopname.png")
- ex.driver.screenshot(shot)
- shop_btn = _find_text_in_area(shot, shop_name, h // 2)
- if shop_btn:
- print(f" 店铺名: ({shop_btn['x']}, {shop_btn['y']}) (第{attempt+1}轮)")
- ex.tap(shop_btn["x"], shop_btn["y"])
- time.sleep(2.5)
- # 下半区找「查看营业资质」,找不到先上滑一次再看
- shot2 = _shot_path(ex, "inst_btn.png")
- ex.driver.screenshot(shot2)
- lic_btn = _find_license_btn()
- if not lic_btn:
- print(f" 未找到「查看营业资质」(第{attempt+1}轮),上滑再看")
- _adb_swipe_up_short(ex)
- shot2 = _shot_path(ex, "inst_btn.png")
- ex.driver.screenshot(shot2)
- lic_btn = _find_license_btn()
- if lic_btn:
- break
- if attempt == 0:
- # 屏幕可能在详情页/说明书页:back 回退一层再看
- print(" back一次回退后再试")
- ex.driver.press("back")
- time.sleep(1.5)
- else:
- print(" ⚠ 资质采集跳过:未找到店铺名或「查看营业资质」")
- return result
- print(f" 查看营业资质: ({lic_btn['x']}, {lic_btn['y']})")
- ex.tap(lic_btn["x"], lic_btn["y"])
- time.sleep(2.5)
- _ai_check_captcha(ex) # 资质页可能出现验证码(无关键词检查环节)
- # 4. 资质页:云端OCR找「资质编号」取值
- shot3 = _shot_path(ex, "lic_no.png")
- ex.driver.screenshot(shot3)
- raw3 = OCR.recognize(shot3, detail="all", engine="cloud")
- license_no = extract_value_after(raw3, "资质编号")
- if license_no:
- # OCR可能把长编号读散(如 "91450800MA5 KEHAHXT"),编号不该有空格,清洗掉
- license_no = license_no.replace(" ", "")
- if not license_no:
- print(" ⚠ 资质采集跳过:未找到资质编号")
- return result
- print(f" 资质编号: {license_no}")
- result["license_no"] = license_no
- # 5. 点资质编号下方约3cm(≈0.2屏高)打开营业执照大图
- # 注意:云端OCR(标准版)无坐标,必须用本地OCR找资质编号的真实位置
- no_x, no_y = None, None
- for r in OCR.recognize(shot3, detail="all"): # 本地引擎,有真实bbox
- t = r["text"].strip().rstrip(":: \t")
- if t.startswith("资质编号"):
- box = r["bbox"]
- no_x = (box[0][0] + box[2][0]) // 2
- no_y = (box[0][1] + box[2][1]) // 2
- break
- if no_y is None:
- print(" ⚠ 资质采集跳过:本地OCR未找到资质编号位置")
- return result
- print(f" 资质编号位置: ({no_x}, {no_y}),点击下方打开执照")
- ex.tap(no_x, min(no_y + int(h * 0.2), h - 50))
- time.sleep(2.5)
- # 6. 营业执照截图 + 百度营业执照专用接口(美团同款;执照在屏幕中下部,先裁剪再识别,逐级兜底)
- shot4 = _shot_path(ex, "lic_license.png")
- ex.driver.screenshot(shot4)
- lic = {}
- for t_ratio, b_ratio in [(0.35, 0.8), (0.4, 1.0), (0.0, 1.0)]:
- lic = OCR.recognize_license(shot4, rect=[0, int(h * t_ratio), w, int(h * b_ratio)])
- if lic:
- break
- if lic:
- print(f" 营业执照: {lic}")
- result["license"] = lic
- else:
- print(" ⚠ 营业执照OCR为空")
- return result
- def _detect_right_col_split(raw: list, w: int) -> Optional[int]:
- """
- 定位列表页"图片|文字"分界x:每张卡片都有价格,¥全在右列且x一致。
- 取¥块左边缘x的中位数 − 15 作为分界(图片区在左、标题/价格在右)。
- 实测3台设备:分界499时图片文字最右仅474,过滤干净。返回None=检测不到(用整图)。
- """
- price_x = sorted(r["box"][0] for r in raw if "¥" in r["text"] or "¥" in r["text"])
- if len(price_x) < 3:
- return None
- return max(price_x[len(price_x) // 2] - 15, int(w * 0.3))
- def _get_named_shops(ex: SafeExecutor, shot_name: str, keyword: str = "") -> tuple:
- """截图 + OCR + AI → 返回 (有店铺名的列表, 本地OCR原始结果带坐标)"""
- shot = _screenshot(ex, shot_name)
- raw = OCR.recognize(shot, detail="all")
- w, h = ex.driver.window_size()
- # 只保留右列(商品描述列):用¥定位分界,过滤左侧图片文字(包装字/英文/乱码)
- split_x = _detect_right_col_split(raw, w)
- if split_x:
- filtered = [r for r in raw if (r["box"][0] + r["box"][2]) // 2 >= split_x]
- if filtered:
- print(f"[step3] 右列识别: 分界x={split_x},过滤掉{len(raw) - len(filtered)}块图片文字")
- raw = filtered
- # 在截图副本上画红线保存(出错时核对分界是否偏左/偏右)
- try:
- img = cv2.imread(shot)
- if img is not None:
- cv2.line(img, (split_x, 0), (split_x, img.shape[0]), (0, 0, 255), 3)
- cv2.putText(img, f"split={split_x}", (split_x + 5, 50),
- cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2)
- cv2.imwrite(shot.replace(".png", "_split.png"), img)
- except Exception:
- pass
- if USE_VISION_PARSER:
- # 方案A:截图送视觉模型(生僻字更准),裁掉左列商品图片只留右列文字区
- # crop_x=split_x 来自OCR分界检测,AI只看文字区,y取标题和店铺名的中点
- shops = VisionParser().parse_shops(shot, screen_size=(w, h), keyword=keyword, crop_x=split_x or 0)
- # AI估y不准会点到上一个店铺,用OCR精确坐标修正
- _correct_vision_coords_with_ocr(shops, raw)
- # 视觉模型失败时 fallback 到 OCR+文本AI
- if not shops:
- print("[step3] 视觉模型未识别到店铺,fallback 到 OCR+文本AI")
- shops = AIParser().parse_shops(raw, screen_size=(w, h), keyword=keyword)
- else:
- # 原方案:本地OCR文本 → 文本AI解析
- shops = AIParser().parse_shops(raw, screen_size=(w, h), keyword=keyword)
- # 只保留有效店铺名+价格:店铺名必须含中文或字母(排除纯数字/标点/空格)
- import re as _re
- valid = []
- for s in shops:
- name = (s[0] or "").strip()
- price = (s[2] or "").strip()
- if name and _re.search(r'[一-鿿-a-zA-Z]', name) and price:
- valid.append(s)
- return valid, raw
- def _find_title_y(raw: list, title: str) -> Optional[int]:
- """在本地OCR结果里找与标题开头重合最多的块的y坐标(标题行位置,用于裁剪)"""
- nt = normalize_match_text(title)
- best_len, best_cy = 0, None
- for r in raw:
- t = normalize_match_text(r["text"])
- if not t:
- continue
- n = 0
- for a, b in zip(t, nt):
- if a == b:
- n += 1
- else:
- break
- if n > best_len:
- best_len = n
- best_cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2
- return best_cy if best_len >= 2 else None
- def _correct_vision_coords_with_ocr(shops: list, raw: list) -> None:
- """用OCR精确坐标修正VisionParser返回的y坐标。
- AI估y不准会点到上一个店铺,用OCR里店铺名和标题的精确y重新算中点。
- 直接修改 shops 列表中的坐标,不返回新列表。"""
- for s in shops:
- shop_name = str(s[0] or "").strip()
- title = str(s[1] or "").strip()
- # 在OCR里找店铺名 → 精确y(子串匹配,取最长匹配的块)
- shop_y = None
- if shop_name and len(shop_name) >= 2:
- ns = normalize_match_text(shop_name)
- best_len, best_y = 0, None
- for r in raw:
- t = normalize_match_text(r["text"])
- if not t or len(t) < 2:
- continue
- if ns in t or t in ns:
- if len(t) > best_len:
- best_len = len(t)
- best_y = (r["bbox"][0][1] + r["bbox"][2][1]) // 2
- shop_y = best_y
- # 在OCR里找标题 → 精确y(复用已有函数)
- title_y = _find_title_y(raw, title) if title else None
- # 用OCR坐标修正点击坐标
- if shop_y is not None and title_y is not None:
- s[3][1] = (title_y + shop_y) // 2 # 点击y = 标题和店铺名中点
- s[4] = shop_y
- print(f" [coord] OCR修正: {shop_name[:10]} y={s[3][1]} (标题y={title_y} 店铺y={shop_y})")
- elif shop_y is not None:
- s[3][1] = max(shop_y - 150, 0) # 只有店铺名y,往上150点击
- s[4] = shop_y
- print(f" [coord] OCR修正(仅店铺名): {shop_name[:10]} y={s[3][1]}")
- def _shop_key(shop: list) -> str:
- """用店铺名+价格去重(去括号内分店名、去尾部点号)"""
- import re
- name = shop[0]
- price = shop[2] if len(shop) > 2 else ""
- name = name.replace("(", "(").replace(")", ")")
- name = re.sub(r'(.*', '', name)
- name = re.sub(r'[..…]+$', '', name)
- return f"{name.strip()}|{price.strip()}"
- def _visit_shop(ex: SafeExecutor, shop: list, visited: set, keyword: str = "", task: dict = None) -> dict:
- """
- 点击进入店铺 → step4 → 说明书 → 快照 → 资质 → 返回完整数据 dict
- task: 调度任务 dict(task_id/enterprise_id/collect_round等),手动模式传 None
- """
- """点击进入店铺 → step4 → 返回完整数据 dict"""
- key = _shop_key(shop)
- if key in visited:
- return None
- visited.add(key)
- shop_name = shop[0]
- product_title = shop[1]
- price = shop[2]
- sales = str(shop[5]) if len(shop) > 5 and shop[5] else ""
- click_x, click_y = shop[3]
- print(f" → 进入 [{shop_name}] 商品: {product_title[:30]} 价格: {price} 月售: {sales}")
- ex.tap(click_x, click_y)
- try:
- qr_url = step4_parse_qr(ex, product_title, shop_name, shop_xy=[click_x, click_y])
- except Exception as e:
- print(f" ⚠ step4异常: {e},跳过此店铺")
- qr_url = ""
- if qr_url == "__TERMINATE__":
- print(f" ⚠ 遇到终止信号,停止遍历")
- return {"__terminate__": True}
- if qr_url == "__RESTART__":
- print(f" ⚠ 页面异常,触发重启恢复")
- return {"__restart__": True}
- if qr_url:
- print(f" ✅ QR: {qr_url[:80]}")
- print(f" 📦 采集完成: {shop_name} | {product_title[:30]} | {price} | 月售{sales} | {qr_url[:60]}")
- else:
- print(f" ⚠ 未获取到二维码链接")
- print(f" 📦 采集完成(无链接): {shop_name} | {product_title[:30]} | {price} | 月售{sales}")
- # 内存熔断:本地OCR已OOM → 说明书/快照/资质全部跳过(状态检测不可靠,避免乱back乱点)
- if getattr(OCR, "oom", False):
- print(" ⚠ 内存不足熔断:跳过说明书/快照/资质,直接返回列表页(请关闭部分程序释放内存)")
- inst = {"approval_no": "", "validity": "", "in_detail": False}
- snapshot_url = ""
- lic = {"license_no": "", "license": {}}
- else:
- # 采完链接后采集说明书(批准文号/有效期),失败不阻塞
- try:
- inst = _collect_instructions(ex)
- except Exception as e:
- print(f" ⚠ 说明书采集异常: {e}(疑似内存不足,请关闭部分程序)")
- inst = {"approval_no": "", "validity": "", "in_detail": False}
- print(f" 📄 说明书: 批准文号={inst['approval_no']} 有效期={inst['validity']}")
- # 采完说明书后采集网页快照(美团同款顺序:说明书→快照→资质),失败不阻塞
- snapshot_url = _collect_snapshot(ex, product_title)
- # 采完快照后采集商家资质(_collect_license 会先 back 回店铺页,再按屏幕状态自校验:列表页/无店铺名都跳过)
- try:
- lic = _collect_license(ex, shop_name)
- except Exception as e:
- print(f" ⚠ 资质采集异常: {e}")
- lic = {"license_no": "", "license": {}}
- print(f" 📋 资质编号: {lic['license_no']} 单位名称: {lic['license'].get('单位名称', '')} 信用代码: {lic['license'].get('社会信用代码', '')}")
- # 返回搜索页:先检测再退(已是列表页则一步不退;退到首页立即停,防止退过头退出app)
- for _ in range(5):
- try:
- pos = _where_am_i(ex)
- except Exception as e:
- print(f" ⚠ 位置检测异常({e}),停止返回")
- break
- if pos == "list":
- break
- if pos == "home":
- print(" ⚠ 已退到首页(可能退过头),停止返回")
- break
- ex.driver.press("back")
- time.sleep(1.4)
- task = task or {}
- return {
- "shop": shop_name,
- "title": product_title,
- "price": price,
- "sales": sales,
- "approval_no": inst["approval_no"],
- "validity": inst["validity"],
- "license_no": lic["license_no"],
- "license": lic["license"],
- "snapshot_url": snapshot_url,
- "search_name": keyword,
- "link": qr_url or "",
- "task_id": task.get("task_id"),
- "enterprise_id": task.get("enterprise_id"),
- "collect_round": task.get("collect_round"),
- "collect_equipment_account_id": task.get("collect_equipment_account_id"),
- "collect_region_id": task.get("collect_region_id"),
- "collect_config_info": task.get("collect_config_info", ""),
- }
- def _captcha_log_path(device_id: str) -> Path:
- """每台设备独立的验证码日志文件(独立计数,互不影响)"""
- return CAPTCHA_LOG_DIR / f"captcha_log_{device_id}.txt"
- def _log_captcha(ex: SafeExecutor) -> bool:
- """
- 记录验证码出现时间到日志,检查一天≥8次停止。
- 返回 True=已触发停止(上层不再休息),False=正常(处理完成后按频率休息)。
- """
- global CAPTCHA_ABORTED, CAPTCHA_ABORT_REASON
- import os as _os
- ts = time.strftime("%Y-%m-%d %H:%M:%S")
- line = f"{ts} 验证码出现"
- try:
- _os.makedirs(_os.path.dirname(_captcha_log_path(ex.device_id)), exist_ok=True)
- with open(_captcha_log_path(ex.device_id), "a", encoding="utf-8") as f:
- f.write(line + "\n")
- except Exception as e:
- print(f" [验证码记录] 写日志失败: {e}")
- print(f" [验证码记录] {line}")
- # 一天内 ≥8次 → 立即停止采集回告(风控可能封号)
- today_count = _captcha_count_today(ex.device_id)
- print(f" [验证码记录] 设备{ex.device_id}今天已出现{today_count}次")
- if today_count >= CAPTCHA_DAILY_LIMIT:
- CAPTCHA_ABORTED = True
- CAPTCHA_ABORT_REASON = f"一天内验证码达{today_count}次,进入风控可能封号"
- print(f" ⚠ {CAPTCHA_ABORT_REASON},停止采集")
- return True
- return False
- def _captcha_count_today(device_id: str) -> int:
- """统计该设备日志中今天(按日期)的验证码次数"""
- try:
- today = time.strftime("%Y-%m-%d")
- count = 0
- with open(_captcha_log_path(device_id), encoding="utf-8") as f:
- for line in f:
- if line.startswith(today):
- count += 1
- return count
- except Exception:
- return 0
- def _save_captcha_check(ex: SafeExecutor) -> None:
- """每次判定出现验证码时截图保存到 logs/captcha_check/{日期}/,人工确认是否真验证码"""
- import os as _os
- try:
- day = time.strftime("%Y-%m-%d")
- d = CAPTCHA_CHECK_DIR / day
- d.mkdir(parents=True, exist_ok=True)
- ts = time.strftime("%H%M%S")
- path = str(d / f"{ts}_{ex.device_id}_captcha.png")
- ex.driver.screenshot(path)
- print(f" [验证码截图] 已保存: {path}")
- except Exception as e:
- print(f" [验证码截图] 保存失败: {e}")
- def _handle_captcha(ex: SafeExecutor, ocr_texts: list) -> bool:
- """处理验证码, 重试5次, 失败等人工, 返回True=已解决;
- 只有自动解决成功才计数(失败走人工的不算),成功后按频率休息"""
- _save_captcha_check(ex) # 判定出现验证码时截图存档(人工确认是否误判)
- import sys as _sys
- _sys.path.insert(0, r"D:\drug\sg\yzm")
- solved = False
- for attempt in range(1, 6):
- print(f" [验证码] 第{attempt}次尝试...")
- nine_kw = any("提交" in t or "没有新图片" in t for t in ocr_texts)
- if nine_kw:
- from nine_grid import solve as solve_nine
- ok = solve_nine(ex.driver)
- else:
- from tmp_captcha_test6 import solve_slider
- ok = solve_slider(ex.driver)
- if ok:
- print(f" ✅ 验证码已解决")
- solved = True
- break
- print(f" ❌ 第{attempt}次失败")
- time.sleep(1)
- if solved:
- # 只有解决成功才计数 + 一天≥8次停止检查 + 每2次休息30分钟
- aborted = _log_captcha(ex)
- if not aborted:
- _captcha_rest(ex)
- else:
- print(f" ⚠ 5次自动处理失败, 请人工处理...(不计数)")
- input(" 处理完成后按回车继续...")
- return True
- def _captcha_rest(ex: SafeExecutor) -> None:
- """验证码处理完成后:每累计2次休息30分钟;休息期间每10分钟回告一次进度(防后台判假死)"""
- today_count = _captcha_count_today(ex.device_id)
- if today_count % CAPTCHA_REST_EVERY == 0 and today_count < CAPTCHA_DAILY_LIMIT:
- print(f" ⏸ 已出现{today_count}次验证码,休息{CAPTCHA_REST_MINUTES}分钟...")
- rest_left = CAPTCHA_REST_MINUTES * 60
- while rest_left > 0:
- chunk = min(rest_left, 600) # 每10分钟一段
- time.sleep(chunk)
- rest_left -= chunk
- # 休息期间每10分钟回告当前进度(后台超过10分钟无回告会判假死)
- if rest_left > 0 and _SCHEDULER is not None:
- try:
- _SCHEDULER.post_report({
- "task_id": _TASK_ID,
- "platform": _SCHEDULER.platform,
- "username": _SCHEDULER.username,
- "is_finished": 0,
- "need_reassign": 0,
- "current_page": CURRENT_PAGE,
- "crawled_count": _CRAWLED_COUNT,
- })
- print(f" [休息中回告] 当前页{CURRENT_PAGE},已采{_CRAWLED_COUNT}条")
- except Exception as e:
- print(f" [休息中回告] 失败: {e}")
- print(f" ▶ 休息结束,继续采集")
- # 验证码强特征词(店铺页/列表页文字多但无这些词——AI判risk时用OCR文字二次确认防误判)
- CAPTCHA_KW = ("拖动滑块", "请按住滑块", "请按照说明", "点我反馈", "进行验证",
- "滑块验证", "拼图", "安全验证", "图形验证", "点击完成验证", "没有新图片", "操作频繁")
- def _has_captcha_kw(texts: list) -> bool:
- """OCR文字是否含验证码强特征词"""
- joined = "".join(texts)
- return any(k in joined for k in CAPTCHA_KW)
- def _ai_check_captcha(ex: SafeExecutor) -> bool:
- """
- AI检测当前屏幕是否出现验证码(用于没有关键词检查的环节):
- 截图 → 本地OCR → 强特征词预筛 → AI判断页面类型(risk=验证码)→ 有则自动处理。
- 返回 True=检测到验证码(已处理或处理中),False=无验证码。
- """
- try:
- shot = _shot_path(ex, "captcha_ai.png")
- ex.driver.screenshot(shot)
- if cv2.imread(shot) is None:
- ex.driver.screenshot(shot)
- raw = OCR.recognize(shot, detail="all") # 本地OCR即可(验证码文字是大字,不用百度)
- # 预筛:OCR文本里连强特征词都没有 → 不是验证码,不调AI(省调用+防误判)
- if not _has_captcha_kw([r["text"] for r in raw]):
- return False
- page = AIParser().check_page(raw)
- if page.get("type") == "risk":
- print(f" ⚠ AI检测到验证码页面: {page.get('detail', '')}")
- _handle_captcha(ex, [r["text"] for r in raw])
- time.sleep(2)
- return True
- return False
- except Exception as e:
- print(f" ⚠ AI验证码检测异常: {e}")
- return False
- def _check_account_kicked(ex: SafeExecutor) -> bool:
- """
- 检测账号是否被踢/封号:登录页元素(me.ele:id/login_onkey_login_ll)出现即判定。
- 检测到 → 置 ACCOUNT_ABORTED 标志(停止采集 + 调度回告)。
- """
- global ACCOUNT_ABORTED
- try:
- if ex.driver.xpath('//*[@resource-id="me.ele:id/login_onkey_login_ll"]').exists:
- print(" ⚠ 检测到账号被踢/封号(登录页),停止采集")
- ACCOUNT_ABORTED = True
- return True
- except Exception as e:
- print(f" ⚠ 封号检测异常: {e}")
- return False
- def step4_parse_qr(ex: SafeExecutor, product_title: str, shop_name: str = "",
- shop_xy: Optional[list] = None) -> str:
- """
- 1. 等待加载 → OCR → AI找商品标题坐标
- 2. 点击商品标题 → 进入商品详情
- 3. 找右上角"分享" → 点击 → 二维码弹窗
- 4. 截图 → pyzbar 解析二维码
- 返回 URL 或空字符串
- """
- # 安全的文件名前缀(用hash避免中文路径cv2兼容问题)
- # 多设备隔离:加入设备ID,防止并发时两台设备写同一个文件
- import hashlib
- _hash = hashlib.md5(shop_name.encode()).hexdigest()[:8] if shop_name else "unknown"
- _pfx = lambda name: _shot_path(ex, f"_s4_{_hash}_{name}")
- time.sleep(6)
- # ── 检测页面类型:验证码/风控/正常 ──
- # unknown恢复流程:等1秒二次截图确认 → 3次确认unknown → back一层+重新点击进入(刷新)→ 再检测 → 仍unknown才重启
- unknown_round = 0
- backed_once = False
- for page_retry in range(9):
- shot_check = _pfx("page_check.png")
- ex.driver.screenshot(shot_check)
- check_raw = OCR.recognize(shot_check, detail="all")
- # 方法A: 模板匹配检测验证码
- import os as _os
- captcha_tpl = str(Path(__file__).parent / "files" / "captcha1.png")
- if _os.path.exists(captcha_tpl):
- si = cv2.imread(shot_check)
- ti = cv2.imread(captcha_tpl)
- if si is not None and ti is not None:
- gs = cv2.cvtColor(si, cv2.COLOR_BGR2GRAY)
- gt = cv2.cvtColor(ti, cv2.COLOR_BGR2GRAY)
- h_s, w_s = gs.shape
- crop_y1, crop_y2 = int(h_s * 0.25), int(h_s * 0.75)
- crop_x1, crop_x2 = 0, 400
- gs_crop = gs[crop_y1:crop_y2, crop_x1:crop_x2]
- scores = []
- for fn, ss, tt in [
- ("gray", gs_crop, gt),
- ("edge", cv2.Canny(gs_crop,30,100), cv2.Canny(gt,30,100)),
- ("hist", cv2.equalizeHist(gs_crop), cv2.equalizeHist(gt)),
- ("blur", cv2.GaussianBlur(gs_crop,(3,3),0), cv2.GaussianBlur(gt,(3,3),0)),
- ("otsu", cv2.threshold(gs_crop,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)[1],
- cv2.threshold(gt,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)[1]),
- ]:
- if ss.ndim == 2 and tt.ndim == 2 and ss.shape[0] >= tt.shape[0] and ss.shape[1] >= tt.shape[1]:
- r = cv2.matchTemplate(ss, tt, cv2.TM_CCOEFF_NORMED)
- _, mv, _, _ = cv2.minMaxLoc(r)
- scores.append((mv, fn))
- if scores:
- best_v = max(s[0] for s in scores)
- best_m = max(scores, key=lambda s: s[0])[1]
- print(f" 验证码模板匹配: {best_m}={best_v:.3f}")
- captcha_kw = any("拖动滑块" in r["text"] or "请按住滑块" in r["text"] or "安全验证" in r["text"] for r in check_raw)
- nine_kw = any("提交" in r["text"] or "没有新图片" in r["text"] for r in check_raw)
- if best_v >= 0.30 and (captcha_kw or nine_kw):
- print(f" ⚠ 检测到验证码,尝试自动处理...")
- if _handle_captcha(ex, [r["text"] for r in check_raw]):
- continue
- return "__TERMINATE__"
- elif best_v >= 0.30 and not captcha_kw:
- print(f" ⚠ 模板匹配命中但OCR无验证码关键词,忽略")
- page_type = AIParser().check_page(check_raw)
- ptype = page_type.get("type", "unknown")
- if ptype == "risk":
- # OCR文字二次确认:店铺页/列表页文字多但无验证码特征词 → 误判,忽略
- if not _has_captcha_kw([r["text"] for r in check_raw]):
- print(" AI判risk但OCR无验证码特征词,忽略(店铺页/列表页误判)")
- continue
- print(f" ⚠ AI检测到验证码,尝试自动处理...")
- if _handle_captcha(ex, [r["text"] for r in check_raw]):
- continue
- return "__TERMINATE__"
- if ptype == "home":
- # 二次确认:截图太早页面没加载完时AI可能误判首页(底部导航"我的"在任何页面都可见)
- time.sleep(1)
- ex.driver.screenshot(shot_check)
- confirm_raw = OCR.recognize(shot_check, detail="all")
- ptype2 = AIParser().check_page(confirm_raw).get("type", "unknown")
- if ptype2 == "home":
- print(" ⚠ 二次确认仍为首页(被踢回/退过头),触发重启恢复")
- return "__RESTART__"
- if ptype2 == "normal":
- break # 页面加载完成,恢复正常
- print(f" AI先判home,二次确认为{ptype2},继续检测")
- continue
- if ptype == "list":
- print(" ⚠ AI检测到列表页(未成功进店),放弃本店")
- return ""
- if ptype == "login":
- global ACCOUNT_ABORTED
- ACCOUNT_ABORTED = True
- print(" ⚠ AI检测到登录页(账号被踢/封号),停止采集")
- return "__TERMINATE__"
- if ptype == "normal":
- break # 正常,跳出重试循环
- # qrcode/unknown:等1秒二次截图确认(点击后立即截图可能页面没加载完,避免误判)
- time.sleep(1)
- ex.driver.screenshot(shot_check)
- confirm_raw = OCR.recognize(shot_check, detail="all")
- ptype2 = AIParser().check_page(confirm_raw).get("type", "unknown")
- if ptype2 == "normal":
- break
- if ptype2 == "risk":
- if not _has_captcha_kw([r["text"] for r in confirm_raw]):
- print(" 二次确认risk但OCR无验证码特征词,忽略")
- continue
- if _handle_captcha(ex, [r["text"] for r in confirm_raw]):
- continue
- return "__TERMINATE__"
- if ptype2 == "home":
- print(" ⚠ 二次确认检测到首页,触发重启恢复")
- return "__RESTART__"
- if ptype2 == "list":
- print(" ⚠ 二次确认检测到列表页,放弃本店")
- return ""
- if ptype2 == "login":
- ACCOUNT_ABORTED = True # 本函数已声明global
- print(" ⚠ 二次确认检测到登录页(账号被踢/封号),停止采集")
- return "__TERMINATE__"
- # 两次都是unknown → 确认计数
- unknown_round += 1
- print(f" 确认unknown(第{unknown_round}次)")
- if unknown_round >= 3:
- if not backed_once:
- # 3次确认unknown:back一层 + 重新点击进入一次(类似刷新)
- print(" 3次确认unknown,back一层并重新点击进入")
- ex.driver.press("back")
- time.sleep(1.5)
- if shop_xy:
- ex.tap(shop_xy[0], shop_xy[1])
- time.sleep(2.5)
- backed_once = True
- unknown_round = 0
- else:
- # 刷新后仍unknown → 重启恢复
- import shutil
- err_dir = SCREENSHOT_DIR / ex.device_id / "step4" / "unrecognized"
- err_dir.mkdir(exist_ok=True)
- shutil.copy(shot_check, str(err_dir / f"unknown_{int(time.time())}.png"))
- print(" ⚠ 刷新后仍unknown,触发重启恢复")
- return "__RESTART__"
- time.sleep(2)
- else:
- # 检测循环耗尽仍未正常 → 重启恢复
- import shutil
- err_dir = SCREENSHOT_DIR / ex.device_id / "step4" / "unrecognized"
- err_dir.mkdir(exist_ok=True)
- shutil.copy(shot_check, str(err_dir / f"unknown_{int(time.time())}.png"))
- print(f" ⚠ 检测循环耗尽({ptype}),触发重启恢复")
- return "__RESTART__"
- # normal → 继续
- # ── 店铺页判断:OCR同时检测到「刚刚搜过」和「评价」说明在店铺页 ──
- in_shop = False
- for _ in range(10):
- shop_check = _pfx("shop_check.png")
- ex.driver.screenshot(shop_check)
- shop_raw = OCR.recognize(shop_check, detail="text")
- has_ganggang = any("刚刚搜过" in t for t in shop_raw)
- has_pingjia = any("评价" in t for t in shop_raw)
- if has_ganggang and has_pingjia:
- in_shop = True
- print(f" 已确认在店铺页")
- break
- time.sleep(1)
- if not in_shop:
- print(f" ⚠ 未检测到店铺页,继续尝试...")
- # ── 第1步:截图 + AI找商品标题坐标(AI失败或返回None时重试3次,页面可能未加载完)──
- system_prompt = """你收到店铺页的OCR文字。商品标题文字坐标已知(从OCR中有x,y)。
- 请找到和以下商品标题匹配的文字块,返回其点击坐标。
- 【重要规则】
- - 坐标必须从OCR数据中选取,不得编造或估算
- - 如果找不到完全匹配的,找最相似的
- - 如果完全找不到,返回null
- 只返回JSON:
- {"title_xy": [x, y] 或 null, "shop": "店铺名"}"""
- parser = AIParser()
- title_xy = None
- for ai_try in range(3):
- shot = _pfx("shop.png")
- ex.driver.screenshot(shot)
- raw = OCR.recognize(shot, detail="all")
- sorted_r = sorted(raw, key=lambda r: r["bbox"][0][1])
- lines = []
- for r in sorted_r:
- cx = (r["bbox"][0][0] + r["bbox"][2][0]) // 2
- cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2
- lines.append(f"[x={cx:4d}, y={cy:4d}] {r['text']}")
- ocr_text = "\n".join(lines)
- resp = parser._call(system_prompt, f"商品标题: {product_title}\n\nOCR文字:\n{ocr_text}\n\n请返回商品标题坐标。")
- import json
- cleaned = resp.strip()
- if cleaned.startswith("```"):
- cl = cleaned.split("\n")
- if cl[0].startswith("```"): cl = cl[1:]
- if cl and cl[-1].strip() == "```": cl = cl[:-1]
- cleaned = "\n".join(cl).strip()
- try:
- data = json.loads(cleaned)
- title_xy = data.get("title_xy")
- except json.JSONDecodeError:
- title_xy = None
- if title_xy and isinstance(title_xy, list) and len(title_xy) == 2:
- break
- print(f" ⚠ AI未返回有效坐标(第{ai_try+1}次): {title_xy},2秒后重试")
- time.sleep(2)
- if not title_xy or not isinstance(title_xy, list) or len(title_xy) != 2:
- print(f" ⚠ AI 3次均未返回有效坐标: {title_xy}")
- return ""
- tx, ty = title_xy
- if tx is None or ty is None:
- print(f" ⚠ AI返回空坐标")
- return ""
- tx, ty = int(tx), int(ty)
- w, h = ex.driver.window_size()
- if not (0 <= tx <= w and 0 <= ty <= h):
- print(f" ⚠ 坐标越界: ({tx},{ty}) 超出屏幕 {w}x{h}")
- return ""
- # ── 第2步:点击商品标题 → 进入商品详情(最多重试3次)──
- entered_detail = False
- for attempt in range(3):
- print(f" 点击商品标题: ({tx},{ty}) (第{attempt+1}次)")
- ex.tap(tx, ty)
- time.sleep(3) # 点击后先等页面响应(验证码处理后/网络慢时切换慢)
- # 检测是否进入商品详情页(验证码检测优先——验证码页文字块少,不能被"加载中"分支挡掉)
- for _ in range(8):
- time.sleep(2)
- detail_check = _pfx("detail_check.png")
- ex.driver.screenshot(detail_check)
- detail_raw = OCR.recognize(detail_check, detail="all")
- detail_texts = [r["text"] for r in detail_raw]
- # 1. 验证码优先检测:验证码页文字块少(可能≤8),必须先于"加载中"判断
- captcha_kw = any("拖动滑块" in t or "请按住滑块" in t or "安全验证" in t for t in detail_texts)
- if captcha_kw:
- print(f" ⚠ 检测到验证码页面,尝试自动处理...")
- if _handle_captcha(ex, detail_texts):
- continue
- return "__TERMINATE__"
- # 1.5 「重新加载」页(出错了/检修中,验证码通过后常见):点重新加载后继续等
- if any("重新加载" in t for t in detail_texts):
- for r in detail_raw:
- if "重新加载" in r["text"]:
- box = r["bbox"]
- bx = (box[0][0] + box[2][0]) // 2
- by = (box[0][1] + box[2][1]) // 2
- print(f" 检测到「重新加载」页,点击重新加载 ({bx},{by})")
- ex.tap(bx, by)
- time.sleep(2)
- break
- continue
- # 2. 页面加载中/切换中(文字少且无验证码)→ 继续等待,不误判
- if len(detail_texts) <= 8:
- print(f" 页面未就绪(仅{len(detail_texts)}块文字),继续等待...")
- continue
- # 2.5 检测是否退回首页/列表页(点标题失败/back过头时常见)→ 立即处理,不盲等
- joined_texts = "".join(detail_texts)
- if ("看病买药" in joined_texts) and ("我的" in joined_texts):
- print(" ⚠ 检测到已退回首页,触发重启恢复")
- return "__RESTART__"
- if "筛选" in joined_texts:
- print(" ⚠ 检测到已回列表页,放弃本店(点标题未成功进店)")
- return ""
- # 3. 检测商品详情页关键词
- if any("加入购物车" in t or "立即购买" in t or "选规格" in t or "商品详情页" in t for t in detail_texts):
- print(f" 已进入商品详情页")
- entered_detail = True
- break
- # 4. 不在详情页,检测是否还在店铺页
- has_ganggang = any("刚刚搜过" in t for t in detail_texts)
- has_pingjia = any("评价" in t for t in detail_texts)
- if has_ganggang and has_pingjia:
- print(f" 仍在店铺页,重试...")
- break # 跳出内层循环
- if entered_detail:
- break
- # 内层循环结束仍未进入详情页:AI判断当前实际页面类型(诊断 + 验证码/首页/列表页兜底)
- try:
- last_shot = _pfx("detail_check.png")
- if cv2.imread(last_shot) is not None:
- ai_raw = OCR.recognize(last_shot, detail="all")
- ai_page = AIParser().check_page(ai_raw)
- print(f" AI页面类型: {ai_page.get('type')} - {ai_page.get('detail', '')[:40]}")
- if ai_page.get("type") == "risk":
- if not _has_captcha_kw([r["text"] for r in ai_raw]):
- print(" AI判risk但OCR无验证码特征词,忽略")
- else:
- print(" ⚠ AI检测到验证码页,尝试自动处理...")
- if _handle_captcha(ex, [r["text"] for r in ai_raw]):
- continue
- return "__TERMINATE__"
- if ai_page.get("type") == "home":
- print(" ⚠ AI检测到首页,触发重启恢复")
- return "__RESTART__"
- if ai_page.get("type") == "list":
- print(" ⚠ AI检测到列表页,放弃本店")
- return ""
- if ai_page.get("type") == "login":
- ACCOUNT_ABORTED = True # 本函数已声明global
- print(" ⚠ AI检测到登录页(账号被踢/封号),停止采集")
- return "__TERMINATE__"
- except Exception as e:
- print(f" ⚠ AI页面类型检测异常: {e}")
- # 第1次失败后,重新OCR+AI获取坐标(可能是页面滚动导致坐标偏移)
- if attempt < 2:
- print(f" 重新OCR获取坐标...")
- re_shot = _pfx("shop.png")
- ex.driver.screenshot(re_shot)
- re_raw = OCR.recognize(re_shot, detail="all")
- re_sorted = sorted(re_raw, key=lambda r: r["bbox"][0][1])
- re_lines = []
- for r in re_sorted:
- r_cx = (r["bbox"][0][0] + r["bbox"][2][0]) // 2
- r_cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2
- re_lines.append(f"[x={r_cx:4d}, y={r_cy:4d}] {r['text']}")
- re_ocr_text = "\n".join(re_lines)
- re_resp = parser._call(system_prompt, f"商品标题: {product_title}\n\nOCR文字:\n{re_ocr_text}\n\n请返回商品标题坐标。")
- re_cleaned = re_resp.strip()
- if re_cleaned.startswith("```"):
- rl = re_cleaned.split("\n")
- if rl[0].startswith("```"): rl = rl[1:]
- if rl and rl[-1].strip() == "```": rl = rl[:-1]
- re_cleaned = "\n".join(rl).strip()
- try:
- re_data = json.loads(re_cleaned)
- re_xy = re_data.get("title_xy")
- if re_xy and isinstance(re_xy, list) and len(re_xy) == 2 and re_xy[0] is not None:
- tx, ty = int(re_xy[0]), int(re_xy[1])
- ww, hh = ex.driver.window_size()
- if not (0 <= tx <= ww and 0 <= ty <= hh):
- print(f" ⚠ 新坐标越界: ({tx},{ty}),保持原坐标")
- else:
- print(f" 新坐标: ({tx},{ty})")
- except Exception:
- pass
- else:
- pass # 3次重试结束
- if not entered_detail:
- print(f" ⚠ 3次点击未进入商品详情页,跳过")
- return ""
- # ── 第3步:ORB特征匹配找分享图标 ──
- share_shot = _pfx("find_share.png")
- ex.driver.screenshot(share_shot)
- screen = cv2.imread(share_shot)
- template_path = str(Path(__file__).parent / "files" / "share.png")
- template = cv2.imread(template_path)
- sx, sy = None, None
- if screen is not None and template is not None:
- h_s, w_s = screen.shape[:2]
- # 右上角区域(分享图标永远在右上)
- roi_x1, roi_y1 = w_s * 2 // 3, 0
- roi = screen[roi_y1:h_s // 4, roi_x1:w_s]
- # 方法A: SIFT 特征匹配(限制右上角区域,减少干扰)
- sx, sy = None, None
- sift = cv2.SIFT_create(nfeatures=1500)
- kp1, des1 = sift.detectAndCompute(template, None)
- kp2, des2 = sift.detectAndCompute(roi, None)
- if des1 is not None and des2 is not None and len(kp1) >= 2 and len(kp2) >= 2:
- bf = cv2.BFMatcher()
- matches = bf.knnMatch(des1, des2, k=2)
- good = []
- for m, n in matches:
- if m.distance < 0.75 * n.distance:
- good.append(m)
- print(f" 分享SIFT(右上区域): 模板{len(kp1)}特征 ROI{len(kp2)}特征 优质{len(good)}")
- if len(good) >= 4:
- src_pts = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
- dst_pts = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
- matrix, _ = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
- if matrix is not None:
- h_t, w_t = template.shape[:2]
- corners = np.float32([[0, 0], [w_t, 0], [w_t, h_t], [0, h_t]]).reshape(-1, 1, 2)
- transformed = cv2.perspectiveTransform(corners, matrix)
- sx = roi_x1 + int(np.mean(transformed[:, 0, 0]))
- sy = int(np.mean(transformed[:, 0, 1]))
- print(f" 分享SIFT匹配: ({sx},{sy})")
- # 方法B: 多尺度模板匹配(右上角区域)
- # 分辨率适配:以 720p 为基准,按屏幕宽度比例调整搜索尺度,覆盖 480p~1080p
- base_w = 720.0
- scale_ratio = w_s / base_w
- # 模板在 720p 下约占 11% 屏宽,目标尺度应使模板覆盖相同比例
- 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]]
- if sx is None:
- best_val, best_loc, best_sw, best_sh = 0, None, 0, 0
- for scale in scales:
- scaled = cv2.resize(template, None, fx=scale, fy=scale)
- sw, sh = scaled.shape[1], scaled.shape[0]
- if sh > roi.shape[0] or sw > roi.shape[1]:
- continue
- res = cv2.matchTemplate(roi, scaled, cv2.TM_CCOEFF_NORMED)
- _, mv, _, ml = cv2.minMaxLoc(res)
- if mv > best_val:
- best_val, best_loc, best_sw, best_sh = mv, ml, sw, sh
- t_edge = cv2.Canny(scaled, 30, 100)
- r_edge = cv2.Canny(roi, 30, 100)
- if t_edge.shape[0] <= r_edge.shape[0] and t_edge.shape[1] <= r_edge.shape[1]:
- res2 = cv2.matchTemplate(r_edge, t_edge, cv2.TM_CCOEFF_NORMED)
- _, mv2, _, ml2 = cv2.minMaxLoc(res2)
- if mv2 > best_val:
- best_val, best_loc, best_sw, best_sh = mv2, ml2, sw, sh
- print(f" 分享模板匹配(右上): 最佳={best_val:.3f}")
- if best_val >= 0.26 and best_loc is not None:
- sx = roi_x1 + best_loc[0] + best_sw // 2
- sy = best_loc[1] + best_sh // 2
- if sx is not None and sy is not None:
- print(f" 分享图标: ({sx},{sy})")
- ex.tap(sx, sy)
- else:
- print(f" ⚠ 未找到分享图标")
- return ""
- # 等弹窗出现,同时记录"分享到"y坐标用于QR裁剪
- share_y = None
- waimai_y = None
- for _ in range(8):
- time.sleep(1)
- ck = _pfx("share_popup.png")
- ex.driver.screenshot(ck)
- detail = OCR.recognize(ck, detail="all")
- texts = [r["text"] for r in detail]
- if any("分享到" in t for t in texts):
- print(f" 分享弹窗出现")
- # 记录"分享到"和"外卖"的y坐标
- for r in detail:
- cy = (r["bbox"][0][1] + r["bbox"][2][1]) // 2
- if "分享到" in r["text"] and share_y is None:
- share_y = cy
- if "外卖" in r["text"] and waimai_y is None:
- waimai_y = cy
- break
- if share_y is None:
- share_y = int(ex.driver.window_size()[1] * 0.74) # fallback
- # ── 第4步:截图 → 多方法解析二维码(多次重试) ──
- import os as _os_debug
- _debug_dir = str(SCREENSHOT_DIR / ex.device_id / "step4" / "debug_qr")
- _os_debug.makedirs(_debug_dir, exist_ok=True)
- # 微信QR解码器(对ECI编码的饿了么QR鲁棒,实测成功率94%)
- _wx_detector = cv2.wechat_qrcode.WeChatQRCode() if hasattr(cv2, "wechat_qrcode") else None
- def _decode_qr(img, share_y, waimai_y=None):
- """基于OCR定位的share_y裁剪QR区域解析"""
- if img is None: return ""
- h, w = img.shape[:2]
- gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
- detector = cv2.QRCodeDetector()
- # 裁剪区域:y从"分享到"上方推算(不依赖"外卖"文字,避免OCR误判)
- # QR 通常在"分享到"上方 15%~35% 屏高范围,取中间偏下
- y_top = max(0, share_y - int(h * 0.30))
- y_bot = share_y
- x_l, x_r = int(w * 0.58), int(w * 0.94)
- crop_save = img[y_top:y_bot, x_l:x_r]
- cv2.imwrite(_pfx("qr_crop.png"), crop_save)
- # 保存调试截图:标注裁剪区域 + QR 边界(按设备ID区分,方便对比不同分辨率)
- debug_img = img.copy()
- cv2.rectangle(debug_img, (x_l, y_top), (x_r, y_bot), (0, 255, 0), 3)
- cv2.putText(debug_img, f"crop:({x_l},{y_top})~({x_r},{y_bot})", (x_l, y_top - 10),
- cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)
- _ts = int(time.time())
- cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_region_{_ts}.png", debug_img)
- # 方法0: 微信QR解码器(对ECI编码的饿了么QR鲁棒,实测94%成功率,全图直接解析)
- if _wx_detector is not None:
- try:
- wx_texts, wx_points = _wx_detector.detectAndDecode(img)
- if wx_texts and wx_texts[0]:
- data = wx_texts[0]
- if wx_points is not None and len(wx_points) > 0:
- wp = wx_points[0].astype(int)
- x1, y1 = wp[:, 0].min(), wp[:, 1].min()
- x2, y2 = wp[:, 0].max(), wp[:, 1].max()
- cv2.rectangle(debug_img, (x1, y1), (x2, y2), (0, 0, 255), 3)
- cv2.putText(debug_img, "WX-QR", (x1, y1 - 10),
- cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
- cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img)
- return data
- except Exception:
- pass
- def _try_decode(roi_gray, zooms=(1,)):
- """在灰度图上尝试多种方式解码"""
- if roi_gray is None or roi_gray.size == 0 or roi_gray.shape[0] == 0 or roi_gray.shape[1] == 0:
- return ""
- # 高分辨率下 QR 可能太大导致 detect 失败,先缩到合理尺寸再解析
- h_roi, w_roi = roi_gray.shape[:2]
- if max(h_roi, w_roi) > 500:
- scale_down = 500 / max(h_roi, w_roi)
- roi_small = cv2.resize(roi_gray, None, fx=scale_down, fy=scale_down, interpolation=cv2.INTER_AREA)
- else:
- roi_small = roi_gray
- for z in zooms:
- if z > 1:
- big = cv2.resize(roi_small, None, fx=z, fy=z, interpolation=cv2.INTER_NEAREST)
- else:
- big = roi_small
- data, _, _ = detector.detectAndDecode(big)
- if data: return data
- # OTSU + zoom
- for z in zooms:
- _, th = cv2.threshold(roi_small, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
- big = cv2.resize(th, None, fx=z, fy=z, interpolation=cv2.INTER_NEAREST) if z > 1 else th
- data, _, _ = detector.detectAndDecode(big)
- if data: return data
- return ""
- # 方法A: 全图detect定位QR → 中心 ±qr_half 精确裁剪解析(qr_half 随分辨率缩放)
- # QR 在全图里 detect 可能失败(干扰太多),但先试试
- ok, points = detector.detect(gray)
- if ok and points is not None and len(points) > 0:
- pts = points[0].astype(int)
- cx = int(np.mean(pts[:, 0]))
- cy = int(np.mean(pts[:, 1]))
- # 根据 detect 到的 QR 边界估算大小,裁剪 QR 中心 ± qr_half
- qr_half = max(int(max(np.linalg.norm(pts[0] - pts[1]), np.linalg.norm(pts[1] - pts[2])) / 2) + 20, 60)
- x1, y1 = max(0, cx - qr_half), max(0, cy - qr_half)
- x2, y2 = min(w, cx + qr_half), min(h, cy + qr_half)
- if x2 > x1 and y2 > y1:
- data = _try_decode(gray[y1:y2, x1:x2], (1, 2, 3))
- if data:
- # 在调试截图上标注 QR 检测位置
- cv2.rectangle(debug_img, (x1, y1), (x2, y2), (0, 0, 255), 3)
- cv2.putText(debug_img, f"QR:({cx},{cy})", (x1, y1 - 10),
- cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
- cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img)
- return data
- # 方法B: 用 detector.detect 在扫描区域内定位 QR → 中心 ± qr_half 精确裁剪解析
- scan_area = gray[y_top:y_bot, x_l:x_r]
- sh, sw = scan_area.shape
- ok2, pts2 = detector.detect(scan_area)
- if ok2 and pts2 is not None and len(pts2) > 0:
- qr_pts = pts2[0].astype(int)
- qx = int(np.mean(qr_pts[:, 0]))
- qy = int(np.mean(qr_pts[:, 1]))
- 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)
- qx1, qy1 = max(0, qx - qr_half), max(0, qy - qr_half)
- qx2, qy2 = min(sw, qx + qr_half), min(sh, qy + qr_half)
- if qx2 > qx1 and qy2 > qy1:
- data = _try_decode(scan_area[qy1:qy2, qx1:qx2], (1, 2, 3))
- if data:
- cv2.rectangle(debug_img, (x_l + qx1, y_top + qy1), (x_l + qx2, y_top + qy2), (0, 0, 255), 3)
- cv2.putText(debug_img, f"QR:({x_l + qx},{y_top + qy})", (x_l + qx1, y_top + qy1 - 10),
- cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
- cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img)
- return data
- # 方法C: 滑动窗口扫描(基于OCR定位区域,尺寸随分辨率缩放)
- win = max(40, min(200, sh // 2, sw // 2))
- step = max(40, win // 3)
- for y in range(0, max(1, sh - win), step):
- for x in range(0, max(1, sw - win), step):
- patch = scan_area[y:y+win, x:x+win]
- data = _try_decode(patch, (1, 2))
- if data:
- # 在调试截图上标注命中的窗口位置
- cv2.rectangle(debug_img, (x_l + x, y_top + y), (x_l + x + win, y_top + y + win), (0, 0, 255), 3)
- cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img)
- return data
- # 方法D: 全图 OTSU + detect → 中心 ± qr_half 精确裁剪解析
- _, full_th = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
- ok3, pts3 = detector.detect(full_th)
- if ok3 and pts3 is not None and len(pts3) > 0:
- qr_pts = pts3[0].astype(int)
- qx = int(np.mean(qr_pts[:, 0]))
- qy = int(np.mean(qr_pts[:, 1]))
- 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)
- qx1, qy1 = max(0, qx - qr_half), max(0, qy - qr_half)
- qx2, qy2 = min(w, qx + qr_half), min(h, qy + qr_half)
- if qx2 > qx1 and qy2 > qy1:
- data = _try_decode(gray[qy1:qy2, qx1:qx2], (1, 2, 3))
- if data:
- cv2.rectangle(debug_img, (qx1, qy1), (qx2, qy2), (0, 0, 255), 3)
- cv2.putText(debug_img, f"QR:({qx},{qy})", (qx1, qy1 - 10),
- cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
- cv2.imwrite(f"{_debug_dir}/{ex.device_id}_qr_found_{_ts}.png", debug_img)
- return data
- return ""
- for retry in range(6): # 最多等 5+2*5=15秒
- time.sleep(5 if retry == 0 else 2)
- qr_shot = _pfx("qr.png")
- ex.driver.screenshot(qr_shot)
- data = _decode_qr(cv2.imread(qr_shot), share_y, waimai_y)
- if data:
- print(f" QR链接: {data[:100]}")
- return data
- return ""
- # ── 步骤 3:滑动 + 逐个点击店铺 ──────────────────────
- def _progress_file_path(device_id: str, keyword: str) -> str:
- """进度文件路径(美团同款:ycwj/{设备}_{药品}.txt)"""
- import hashlib
- safe = hashlib.md5(keyword.encode()).hexdigest()[:8]
- return str(Path(__file__).parent / "ycwj" / f"{device_id}_{safe}.txt")
- def _save_progress(device_id: str, keyword: str, visited: set, scroll_px: int, batch_no: int) -> None:
- """保存采集进度(每批滑动后调用,异常退出时进度已在)"""
- import os
- try:
- path = _progress_file_path(device_id, keyword)
- os.makedirs(os.path.dirname(path), exist_ok=True)
- data = {
- "visited": sorted(visited),
- "scroll_px": scroll_px,
- "batch_no": batch_no,
- "time": time.strftime("%Y-%m-%d %H:%M:%S"),
- }
- with open(path, "w", encoding="utf-8") as f:
- json.dump(data, f, ensure_ascii=False, indent=2)
- except Exception as e:
- print(f"[step3] 保存进度失败: {e}")
- def _load_progress(device_id: str, keyword: str):
- """读取采集进度,无进度文件返回 None"""
- import os
- try:
- path = _progress_file_path(device_id, keyword)
- if not os.path.exists(path):
- return None
- with open(path, "r", encoding="utf-8") as f:
- return json.load(f)
- except Exception as e:
- print(f"[step3] 读取进度失败: {e}")
- return None
- def _delete_progress(device_id: str, keyword: str) -> None:
- """任务正常完成后删除进度文件"""
- import os
- try:
- path = _progress_file_path(device_id, keyword)
- if os.path.exists(path):
- os.remove(path)
- print(f"[step3] 进度文件已删除: {path}")
- except Exception as e:
- print(f"[step3] 删除进度失败: {e}")
- def _spec_ok(title: str, spec_list: list) -> bool:
- """
- 标题是否包含任一目标规格(美团 is_link_spec_useful 同款)。
- 规格是数字+单位,OCR对数字错误率极低,用精确匹配(模糊匹配会把"10袋"误配到"10g")。
- """
- if not spec_list:
- return True
- nt = normalize_match_text(title)
- return any(normalize_match_text(s) in nt for s in spec_list)
- def step3_swipe_and_enter(ex: SafeExecutor, keyword: str, brand: str = "", task: dict = None,
- scheduler=None) -> list:
- """
- 截图 → AI分析 → 逐个点击全部可见店铺 → 下滑加载更多 → 继续点击 → 直到全部遍历
- 品牌+药品名过滤(美团 is_link_useful 同款):标题必须同时包含品牌名和药品名,
- 否则过滤;连续30个无关商品则任务结束停止采集。
- task: 调度任务 dict(task_id/enterprise_id/collect_round/current_page等,手动模式传 None)
- scheduler: 调度器(逐页回告进度,手动模式传 None)
- """
- print("\n" + "=" * 40)
- print(" 步骤 3:遍历店铺")
- print("=" * 40)
- # 新任务开始:重置所有停止标志和缓存(上个任务的验证码/封号停止不能污染本任务)
- global _CLOSE_BTN_CACHE, CAPTCHA_ABORTED, CAPTCHA_ABORT_REASON, ACCOUNT_ABORTED, CURRENT_PAGE
- global _SCHEDULER, _TASK_ID, _CRAWLED_COUNT
- _CLOSE_BTN_CACHE = None
- CAPTCHA_ABORTED = False
- CAPTCHA_ABORT_REASON = ""
- ACCOUNT_ABORTED = False
- CURRENT_PAGE = 0
- _SCHEDULER = scheduler
- _TASK_ID = (task or {}).get("task_id")
- _CRAWLED_COUNT = 0
- w, h = ex.driver.window_size()
- batch_no = 0
- empty_streak = 0 # 连续没有新店铺的批次数
- all_results = []
- unrelated = 0 # 连续无关商品计数(美团同款;>=30 停止采集)
- n_brand = normalize_match_text(brand)
- # 目标规格(调度任务/手动 --spec 传入,如 "120粒|60粒"),用于规格过滤+入库
- spec_raw = str((task or {}).get("product_specs") or "")
- spec_list = (task or {}).get("spec_list") or []
- if isinstance(spec_list, str): # 兼容直接传字符串
- spec_list = [s.strip() for s in re.split(r'[|、,,\n\r]+', spec_list) if s.strip()]
- n_key = normalize_match_text(keyword)
- stopped = False
- # 恢复进度:
- # 1. 跨设备接力:task 带 current_page(调度重派时给)→ 按页码滑动恢复(每页=0.7屏高,跨分辨率一致)
- # 2. 同设备异常恢复:本地进度文件(visited + 滑动px,精确恢复)
- start_page = int((task or {}).get("current_page") or 0)
- progress = _load_progress(ex.device_id, keyword)
- visited = set()
- total_scroll_px = 0
- if start_page > 0:
- print(f"[step3] 跨设备接力恢复: 调度页码={start_page},滑动{start_page}页...")
- for i in range(start_page):
- _adb_swipe_up(ex, int(h * 0.7))
- time.sleep(2)
- elif progress:
- visited = set(progress.get("visited") or [])
- total_scroll_px = int(progress.get("scroll_px") or 0)
- print(f"[step3] 恢复进度: 已访问{len(visited)}个店铺,需滑动恢复{total_scroll_px}px")
- remain = total_scroll_px
- while remain > 0:
- step = min(remain, int(h * 0.5))
- _adb_swipe_up(ex, step)
- remain -= step
- time.sleep(2)
- else:
- visited = set()
- MAX_RESTART = 3 # unknown页面异常时最大重启恢复次数(超过则终止任务)
- restart_count = 0
- while True:
- if CAPTCHA_ABORTED:
- print(f"[step3] {CAPTCHA_ABORT_REASON or chr(39)+chr(39)}停止采集")
- break
- _check_account_kicked(ex) # 每批检测账号是否被踢/封号(xpath检查,开销小)
- if ACCOUNT_ABORTED:
- print("[step3] 账号被踢/封号,停止采集")
- break
- named, raw_local = _get_named_shops(ex, f"step3_b{batch_no}.png", keyword)
- if _ai_check_captcha(ex): # 列表页每批检测验证码(风控弹窗可能出现在列表)
- time.sleep(1)
- named, raw_local = _get_named_shops(ex, f"step3_b{batch_no}.png", keyword) # 验证码处理后重新识别
- raw_new = [s for s in named if _shop_key(s) not in visited]
- # 品牌+药品名+规格过滤(测试OCR准确率时可关闭:ENABLE_TITLE_FILTER=False 直接采集全部结果)
- if not ENABLE_TITLE_FILTER:
- # 测试模式:不过滤,直接采集OCR识别到的全部结果
- new_ones = list(raw_new)
- else:
- cloud_cache = {"done": False, "texts": None}
- shot_path = _shot_path(ex, f"step3_b{batch_no}.png")
- new_ones = []
- for s in raw_new:
- # 裁剪确认用标题块自己的坐标(卡片中心裁剪会漏掉标题行)
- title_y = _find_title_y(raw_local, str(s[1] or ""))
- if title_y is None and len(s) > 3 and s[3]:
- title_y = int(s[3][1])
- v, v_reason = _match_verify(str(s[1] or ""), n_brand, n_key, shot_path, cloud_cache,
- card_y=title_y)
- if v in ("ok", "fuzzy"):
- # 规格过滤(美团 is_link_spec_useful 同款):标题需包含任一目标规格
- if spec_list and not _spec_ok(str(s[1] or ""), spec_list):
- unrelated += 1
- print(f"[step3] 过滤: {str(s[1])[:30]} 不含目标规格{spec_list} (连续{unrelated}个无关)")
- if unrelated >= 30:
- print(f"[step3] 连续{unrelated}个非目标商品,任务结束停止采集")
- stopped = True
- break
- continue
- unrelated = 0
- # 云端验证过:生僻字修正开关开启时才用云端文本修正标题错字(默认关闭,标题保持OCR原样)
- if v == "ok" and ENABLE_RARE_CHAR_CORRECT and cloud_cache["texts"] is not None:
- s = list(s)
- s[1] = _correct_title_with_cloud(str(s[1]), n_brand, n_key, cloud_cache["texts"])
- new_ones.append(s)
- else:
- unrelated += 1
- print(f"[step3] 过滤: {str(s[1])[:30]} 不含目标{v_reason} (连续{unrelated}个无关)")
- if unrelated >= 30:
- print(f"[step3] 连续{unrelated}个非目标商品,任务结束停止采集")
- stopped = True
- break
- if stopped:
- print("[step3] 停止采集,返回已采结果")
- break
- print(f"[step3] 批次{batch_no}: 共{len(named)}个, 新{len(raw_new)}个, 通过过滤{len(new_ones)}个")
- # 逐页回告调度:页码 = 起点页码 + 批次数(每滑一屏算一页;global已在任务开头声明)
- CURRENT_PAGE = start_page + batch_no
- _CRAWLED_COUNT = len(all_results)
- if scheduler is not None:
- try:
- scheduler.post_report({
- "task_id": (task or {}).get("task_id"),
- "platform": scheduler.platform,
- "username": scheduler.username,
- "is_finished": 0,
- "need_reassign": 0,
- "current_page": start_page + batch_no,
- "crawled_count": len(all_results),
- })
- except Exception as e:
- print(f"[step3] 逐页回告失败: {e}")
- if not new_ones:
- empty_streak += 1
- print(f"[step3] 无新店铺 (连续{empty_streak}/3)")
- if empty_streak >= 3:
- print(f"[step3] 连续3批无新店铺,结束")
- break
- # 滑动后再试
- print(f"[step3] 滑动查看下一批")
- if len(named) >= 2:
- target_y = named[-2][4]
- swipe_dist = max(target_y - int(h * 0.15), int(h * 0.15))
- else:
- swipe_dist = int(h * 0.3)
- _adb_swipe_up(ex, swipe_dist)
- total_scroll_px += swipe_dist
- time.sleep(2)
- batch_no += 1
- _save_progress(ex.device_id, keyword, visited, total_scroll_px, batch_no)
- continue
- empty_streak = 0 # 有新店铺,重置计数
- restart_requested = False
- for shop in new_ones:
- result = _visit_shop(ex, shop, visited, keyword, task)
- if result and result.get("__terminate__"):
- print("[step3] 收到终止信号,停止遍历")
- all_results = [r for r in all_results if not r.get("__terminate__")]
- stopped = True
- break
- if result and result.get("__restart__"):
- restart_requested = True
- break
- if result:
- result["brand"] = brand
- result["product_specs"] = spec_raw
- all_results.append(result)
- save_record(result) # 每采完一个立即入库,中断不丢数据
- if restart_requested:
- # unknown页面异常:重启App恢复(进度按页保存,visited保留在内存)
- restart_count += 1
- if restart_count >= MAX_RESTART:
- print(f"[step3] 重启恢复超限({MAX_RESTART}次),终止任务")
- stopped = True
- break
- print(f"[step3] unknown页面异常,重启App恢复(第{restart_count}/{MAX_RESTART}次)")
- _save_progress(ex.device_id, keyword, visited, total_scroll_px, batch_no)
- step1_open_app(ex)
- step2_search(ex, (brand + keyword).strip() or keyword)
- # 从列表顶部滑动恢复到上次位置
- remain = total_scroll_px
- while remain > 0:
- step = min(remain, int(h * 0.5))
- _adb_swipe_up(ex, step)
- remain -= step
- time.sleep(2)
- continue
- if stopped:
- break
- # 正常:滑动到倒数第二个卡片的配送距离位置,进入下一批
- print(f"[step3] 已访问 {len(visited)} 个,滑动查看下一批")
- if len(named) >= 2:
- target_y = named[-2][4] # 倒数第二个卡片的配送距离y坐标
- swipe_dist = max(target_y - int(h * 0.15), int(h * 0.15))
- else:
- swipe_dist = int(h * 0.3)
- _adb_swipe_up(ex, swipe_dist)
- total_scroll_px += swipe_dist
- time.sleep(2)
- batch_no += 1
- _save_progress(ex.device_id, keyword, visited, total_scroll_px, batch_no) # 每页保存一次进度
- continue
- # 任务结束:只有正常完成才删除进度文件;验证码/封号等异常停止时保留(续采位置不丢)
- if not CAPTCHA_ABORTED and not ACCOUNT_ABORTED:
- _delete_progress(ex.device_id, keyword)
- else:
- print(f"[step3] 异常停止,保留进度文件以便恢复: {_progress_file_path(ex.device_id, keyword)}")
- # ── 输出最终结果表 ──
- print("\n" + "=" * 70)
- print(f" 最终结果 ({len(all_results)} 个店铺)")
- print("=" * 70)
- for i, r in enumerate(all_results, 1):
- link_short = r["link"][:55] + "..." if len(r["link"]) > 55 else r["link"]
- print(f" [{i}] {r['shop']}")
- print(f" 商品: {r['title'][:40]}")
- print(f" 价格: {r['price']}")
- print(f" 月售: {r.get('sales', '')}")
- print(f" 批准文号: {r.get('approval_no', '')}")
- print(f" 有效期: {r.get('validity', '')}")
- print(f" 资质编号: {r.get('license_no', '')}")
- lic = r.get("license") or {}
- print(f" 执照: {lic.get('单位名称', '')} 信用代码:{lic.get('社会信用代码', '')} 法人:{lic.get('法人', '')}")
- print(f" 执照地址: {lic.get('地址', '')[:40]}")
- print(f" 链接: {link_short}")
- print()
- return all_results
- # ── 步骤 4:(保留,当前为空 ──────────────────────────
- def step4_empty(ex: SafeExecutor):
- """占位,供后续扩展"""
- pass
- # ── 主入口 ──────────────────────────────────────────────
- if __name__ == "__main__":
- # 启动时自动清理:debug_qr 调试图只保留1天
- try:
- for p in SCREENSHOT_DIR.glob("*/step4/debug_qr/*.png"):
- if time.time() - p.stat().st_mtime > 86400:
- p.unlink(missing_ok=True)
- except Exception:
- pass
- args = sys.argv[1:]
- device_id = "RG5LFYT8UKK7BI95"
- brand = "三九胃泰"
- keyword = "养胃舒颗粒"
- spec_raw = ""
- spec_list = []
- # 解析 --device / --brand / --keyword / --spec 参数(品牌和药品名分开传,对接调度系统)
- filtered = []
- i = 0
- while i < len(args):
- if args[i] == "--device" and i + 1 < len(args):
- device_id = args[i + 1]
- i += 2
- elif args[i] == "--brand" and i + 1 < len(args):
- brand = args[i + 1]
- i += 2
- elif args[i] == "--keyword" and i + 1 < len(args):
- keyword = args[i + 1]
- i += 2
- elif args[i] == "--spec" and i + 1 < len(args):
- spec_raw = args[i + 1]
- spec_list = [s.strip() for s in re.split(r'[|、,,\n\r]+', spec_raw) if s.strip()]
- i += 2
- else:
- filtered.append(args[i])
- i += 1
- cmd = filtered[0] if filtered else "all"
- if not keyword:
- keyword = filtered[1] if len(filtered) > 1 else "矿泉水"
- # 搜索词 = 品牌+药品名+规格 合起来(美团同款:分开配置,搜索时合并)
- search_key = (brand + keyword + spec_raw).strip() or keyword
- print(f"品牌: {brand or '(无)'} | 药品名: {keyword} | 规格: {spec_list or '(不限)'} | 搜索词: {search_key}")
- print("设备连接中...")
- device_id = _find_device(device_id)
- print(f"设备: {device_id}")
- ex = SafeExecutor(device_id)
- if cmd in ("all", "step1"):
- ok = step1_open_app(ex)
- if not ok:
- sys.exit(1)
- if cmd in ("all", "step2"):
- ok = step2_search(ex, search_key)
- if not ok:
- sys.exit(1)
- if cmd in ("all", "step3"):
- task = {"spec_list": spec_list, "product_specs": spec_raw}
- visited = step3_swipe_and_enter(ex, keyword, brand, task)
- print(f"\n最终访问: {visited}")
- sys.exit(0)
- if cmd in ("all", "step4"):
- # step4 需要先跑完 step3 获取所有商品标题,单独跑时需要手动传标题
- title = keyword
- link = step4_parse_qr(ex, title)
- print(f"\n链接: {link}")
- sys.exit(0)
- sys.exit(0)
|