| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325 |
- """
- 饿了么闪购 — 主入口 V1
- 步骤驱动,按步执行
- """
- import sys
- import os
- import time
- import re
- import json
- import shutil
- import cv2
- import numpy as np
- from pathlib import Path
- from typing import Optional
- sys.path.insert(0, str(Path(__file__).parent / "steps"))
- sys.path.insert(0, str(Path(__file__).parent))
- from ocr import OCR
- from executor import SafeExecutor
- from ai_helper1 import AIParser, parse_instructions, extract_value_after, detect_popup
- from ai_helper_vision2 import VisionParser # 起送行锚定版(纯规则零AI, 2026-09-20联调通过; 回滚=改回 ai_helper_vision_cross)
- from db import save_record, get_existing_license
- from snapshot import collect_snapshot
- from human_touch import (wrist_arc_pts, wrist_arc_pts_h, motionevent_drag, touchpipe_drag,
- wrist_arc_pts_side, RIGHT_SIDE_ZONE, LEFT_SIDE_ZONE)
- from commons import err_log
- # ── 配置 ────────────────────────────────────────────────
- APP_PACKAGE = "me.ele"
- SCREENSHOT_DIR = Path(__file__).parent / "screenshots"
- # 标题过滤开关:
- # True = 按品牌/药品名/规格过滤,只采目标商品(正常模式)
- # False = 不过滤,直接采集OCR识别的全部结果入库(测试OCR准确率用)
- ENABLE_TITLE_FILTER = False
- # 详情页AI提取的店铺名(step4进店确认时更新,供Excel对比列表页OCR店名用)
- AI_SHOP_NAME = ""
- # 验证码/休息策略:
- # 一天内 ≥CAPTCHA_DAILY_LIMIT 次 → 立即停止采集并回告(风控可能封号)
- # 每次验证码解决后 → 休息 CAPTCHA_REST_MINUTES~CAPTCHA_REST_MAX_MINUTES 分钟(随机)
- # 每爬 ROW_REST_EVERY 条数据 → 休息 ROW_REST_MIN~ROW_REST_MAX 分钟(随机,期间回告防假死)
- CAPTCHA_DAILY_LIMIT = 8
- CAPTCHA_REST_MINUTES = 1 # 验证码后休息最短分钟数
- CAPTCHA_REST_MAX_MINUTES = 5 # 验证码后休息最长分钟数
- ROW_REST_EVERY = 50 # 每爬多少条休息一次
- ROW_REST_MIN = 10 # 50条整点休息最短分钟数
- ROW_REST_MAX = 17 # 50条整点休息最长分钟数
- # 进入商品页unknown(页面没加载出来)时的处理:
- # 每确认一次unknown → back一层+重新点击进入;累计UNKNOWN_REENTER_LIMIT次加载全失败 → 跳过本商品
- # 连续2个商品都这样 → step3白屏恢复:关App休息60~120s → 重启App(最多MAX_WHITE_RESTART次)→ 超限回告调度重派
- UNKNOWN_REENTER_LIMIT = 5
- MAX_WHITE_RESTART = 3 # 白屏重启恢复上限(超过→置重派标志,回告调度换设备,不终止任务数据)
- WHITE_SUCCESS_RESET_N = 5 # 连续成功采N个商品后清零白屏重启计数(额度恢复)
- WHITE_EXCEPTION_TYPE = 5 # 白屏重派回告exception_type(复用通用异常值5,remark区分具体原因)
- SEARCH_ROUNDS = 5 # step2搜索失败重启重试轮数(共5轮,第2轮起每轮前重启App,全部失败才报错)
- # 推荐流检测:卡片标题与「品牌和药品名都不相关」连续达此数 → 判定搜索结果已耗尽、
- # 列表进入推荐区 → 结束任务(独立于ENABLE_TITLE_FILTER,生产关闭过滤时也生效)。
- # 注意"品牌或药名任一命中"即算相关:同品牌兄弟产品(如999皮炎平/999感冒灵混排)不触发
- RECOMMEND_STREAK_END = 20
- EMPTY_BATCH_SAFETY = 8 # 无新店铺批次的保底停止数(防纯弹跳底部无限循环;推荐流检测正常时到不了这里)
- # 列表上滑策略概率(每次滑动随机): 右手两段式 / 左手两段式 / 原单笔(三者合计1.0)
- SWIPE_RIGHT_TWO_P = 0.80
- SWIPE_LEFT_TWO_P = 0.10
- CAPTCHA_LOG_DIR = Path(__file__).parent / "logs" # 验证码日志目录(每设备独立一个文件,独立计数)
- CAPTCHA_ABORTED = False # 全局标志:验证码频繁触发停止采集(调度上报用)
- CAPTCHA_ABORT_REASON = "" # 停止原因(调度回告用)
- ACCOUNT_ABORTED = False # 全局标志:账号被踢/封号,停止采集(调度上报用)
- WHITE_SCREEN_REASSIGN = False # 全局标志:商品页反复白屏,停止采集并回告调度重派(调度上报用)
- WHITE_SCREEN_REASSIGN_REASON = "" # 重派原因(调度回告remark用)
- _LAST_SKIP_WHITE = False # step4最后一次__SKIP__是否白屏(日志标注用)
- CURRENT_PAGE = 0 # 当前页码(逐页回告/终止回告用,调度侧存续采集进度)
- # 验证码判定截图存档目录(每次判定出现验证码时截图保存,人工确认是否误判)
- CAPTCHA_CHECK_DIR = Path(__file__).parent / "logs" / "captcha_check"
- # 休息期间进度回告用的全局上下文(step3 任务开始时设置,_captcha_rest 休息时每10分钟回告)
- _SCHEDULER = None
- _TASK_ID = None
- _CRAWLED_COUNT = 0
- _ROW_REST_MARK = 0
- OCR = OCR()
- def human_sleep(seconds: float, jitter: float = 0.3):
- """拟人等待:标称时长 ±30% 随机抖动(操作节奏不被风控建模成固定周期)。
- 仅用于 UI 操作间的节奏等待;功能性等待(回告间隔/截图重试)仍用 time.sleep 精确控制。"""
- import random as _random
- try:
- base = float(seconds)
- except (TypeError, ValueError):
- base = 1.0
- time.sleep(max(0.1, base * _random.uniform(1 - jitter, 1 + jitter)))
- # 说明书打叉坐标缓存(仅本任务内有效):任务中第一个商品模板匹配找到打叉后,
- # 本任务后续商品直接复用该坐标(说明书页布局固定,同设备位置不变);
- # 新任务开始时清空,重新匹配。
- _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 _save_evidence(shot: str, tag: str):
- """决策留档: 触发重启/放弃时刻的截图另存时间戳副本。
- page_check/detail_check 同一家店内会被下一轮覆盖,
- 留档(时间戳+原因标签)便于事后人工核对AI是否误判。"""
- try:
- if shot and os.path.exists(shot):
- ts = time.strftime("%Y%m%d_%H%M%S")
- shutil.copy2(shot, str(Path(shot).parent / f"{ts}_{tag}.png"))
- except Exception:
- pass
- 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
- # 截图后验证完整性(adb 流式传输可能中断,导致 PNG 损坏),连续5次失败才抛异常
- last_err = None
- for _try in range(5):
- try:
- ex.driver.screenshot(path)
- except Exception as e:
- last_err = e
- time.sleep(1)
- continue
- if cv2.imread(path) is not None:
- return path
- time.sleep(0.5)
- raise RuntimeError(f"截图连续5次损坏/失败: {name} ({last_err})")
- 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"
- # ── 步骤 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)
- human_sleep(2)
- print(f"[step1] 启动 {APP_PACKAGE}...")
- ex.driver.app_start(APP_PACKAGE)
- human_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
- human_sleep(3)
- print("[step1] FAIL - 多次重试未检测到「我的」")
- # 识别不到"我的"可能因为账号被踢/封号——检测登录页xpath确认(存在才是封号)
- _check_account_kicked(ex)
- return False
- # ── 步骤 2:搜索商品 ────────────────────────────────────
- def _click_low_price_sort(ex: SafeExecutor) -> bool:
- """
- 搜索结果排序:点「综合」→ 选「低价优先」(失败不阻塞,找不到就跳过)。
- 找不到「低价优先」时再点一次「综合」重试,仍找不到则关闭排序弹窗。
- 备用函数:默认不调用(排序已在 step2 快递分支内联),需要按低价优先采集时手动启用。
- """
- 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"])
- human_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"])
- human_sleep(3)
- return True
- # 3. 没找到:再点一次「综合」关闭排序弹窗(点击综合弹出面板,再点一次收起)
- print(" ⚠ 未找到「低价优先」,再点一次「综合」关闭排序弹窗")
- ex.tap(btn["x"], btn["y"])
- human_sleep(1.5)
- return False
- except Exception as e:
- print(f" ⚠ 排序异常: {e}")
- return False
- def _search_once(ex: SafeExecutor, keyword: str) -> bool:
- """单轮搜索(原step2_search主体): 看病买药→搜索→输入→结果确认, 失败返回False"""
- print("\n" + "=" * 40)
- print(f" 步骤 2:搜索「{keyword}」")
- print("=" * 40)
- global _POPUP_RESTART_COUNT
- _POPUP_RESTART_COUNT = 0 # 每次搜索重置弹窗重启计数(仅本步骤内最多3次)
- 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:点击「看病买药」(首页频道宫格加载慢 → 轮询等待最多~25s,不急着重启;
- # 重启=冷启动反而更慢)
- btn_med = None
- for wait_i in range(10):
- shot0 = _screenshot(ex, "step2_phase0.png")
- btn_med = _find_text_in_area(shot0, "看病买药", h)
- if btn_med:
- break
- if wait_i == 0:
- print("[step2] 未找到「看病买药」,等待首页频道宫格加载...")
- human_sleep(2.5)
- if not btn_med:
- print(f"[step2] 等待25s仍未找到「看病买药」(第{round_idx+1}轮)")
- continue
- print(f"[step2] 找到「看病买药」: ({btn_med['x']}, {btn_med['y']})")
- ex.tap(btn_med["x"], btn_med["y"])
- human_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)
- human_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)
- human_sleep(5)
- # 搜索页确认:「搜索」位置变化才认为进入(加载慢/弹窗遮挡/点击未生效就重试)
- moved = False
- btn2 = None
- for attempt in range(3):
- try:
- shot2 = _screenshot(ex, "step2_phase2.png")
- btn2 = _find_text_in_area(shot2, "搜索", top_th)
- except Exception as e:
- print(f"[step2] 截图/识别异常(第{attempt+1}次): {e},重试")
- human_sleep(2)
- continue
- 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)
- human_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)
- human_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)
- human_sleep(1)
- ex.tap(btn2["x"], btn2["y"])
- human_sleep(3)
- # 搜索后检测列表页:先处理验证码(可能挡住列表页),再检测列表页特征(加载慢就重试)
- for attempt in range(5):
- try:
- shot3 = _screenshot(ex, "step2_result.png")
- raw3 = OCR.recognize(shot3, detail="all")
- except Exception as e:
- print(f"[step2] 截图/识别异常(第{attempt+1}次): {e},重试")
- human_sleep(2)
- continue
- 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] 已点击重新加载")
- human_sleep(2)
- continue
- if _ai_check_captcha(ex):
- print(f"[step2] AI检测到验证码(第{attempt+1}次),处理完成")
- continue
- # 1.2 广告/活动弹窗(全屏活动页——无遮罩无列表标记时才检测; 列表页上有"红包"字样是正常的)
- if not any(("筛选" in t or "快递" in t) for t in all_texts) and _close_ad_popup(ex, force=True):
- # back可能只是导航回了上一页(搜索建议页),页面状态已不可信——
- # 必须重发搜索(重新点搜索按钮),下一轮再确认结果页
- print(f"[step2] 已关闭活动弹窗(第{attempt+1}次),重发搜索")
- ex.tap(btn2["x"], btn2["y"])
- human_sleep(3)
- 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)
- human_sleep(3)
- break
- # 点击「快递」后设置排序:综合 → 低价优先
- # _click_low_price_sort(ex) ← 需要按低价优先采集时,去掉行首#即可启用
- print("[step2] OK - 搜索成功")
- return True
- print(f"[step2] 列表页特征未出现(第{attempt+1}次),等3秒重试")
- human_sleep(3)
- print("[step2] FAIL - 搜索未成功")
- return False
- def step2_search(ex: SafeExecutor, keyword: str) -> bool:
- """搜索总入口:单轮搜索失败/异常 → 重启App重试,共SEARCH_ROUNDS轮,全部失败才报错"""
- for round_idx in range(SEARCH_ROUNDS):
- if round_idx > 0:
- print(f"\n[step2] 第{round_idx}轮搜索失败,重启App重试(重启{round_idx}/{SEARCH_ROUNDS - 1}次)")
- if not step1_open_app(ex):
- print("[step2] 重启App失败,直接报错")
- return False
- try:
- if _search_once(ex, keyword):
- return True
- print(f"[step2] 第{round_idx + 1}/{SEARCH_ROUNDS}轮搜索未成功")
- except Exception as e:
- # 截图损坏/识别异常/网络抖动等都按本轮失败处理,重启后再试
- print(f"[step2] 第{round_idx + 1}轮搜索异常({e}),重启后重试")
- print(f"[step2] FAIL - {SEARCH_ROUNDS}轮搜索(含重启)均未成功")
- 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_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 _is_white_screen(shot_path: str) -> bool:
- """白屏检测:截图缩至90x200灰度后均值>250判白(详情页没加载出来的纯白页)。
- 单指标风格同 _page_moved;状态栏/加载圈等少量深色像素拉低不了均值。"""
- img = cv2.imread(shot_path, cv2.IMREAD_GRAYSCALE)
- if img is None:
- time.sleep(0.5)
- img = cv2.imread(shot_path, cv2.IMREAD_GRAYSCALE)
- if img is None:
- return False
- return float(np.mean(cv2.resize(img, (90, 200)))) > 250.0
- def _looks_like_app_home(raw: list) -> bool:
- """真App首页判定(多特征组合,特征证据@screenshots/*/step1/step1_home.png):
- 强锚 = 底部导航「AI点外卖」(全App唯此一处;店铺页/列表页/详情页都没有。
- OCR可能把AI读成Al/a1,统一小写后用「点外卖」宽容匹配)
- 组合 = 频道宫格名(看病买药/盒马鲜生/超市便利/美食外卖/到店团购/天天红包/赚吃货豆/数码潮玩)
- 命中≥3个(宫格可能被滑出屏幕,按在屏内容计;盒马鲜生兼容"盒马生鲜"误读)
- 排除 = 首页不可能有「刚刚搜过」(店铺页专属元素)
- 背景:店铺页自带「首页/商品/评价」页签,AI仅凭"首页"字样会把店铺页误判成home
- (实测@阿里健康自营大药房 2026-09-18 10:00),故AI判home后必须经此复核。"""
- if not raw:
- return False
- joined = "".join(normalize_match_text(r.get("text", "")) for r in raw).lower()
- if "刚刚搜过" in joined:
- return False
- if "点外卖" in joined: # AI点外卖(AI/Al/a1变体一并覆盖)
- return True
- hits = sum(1 for m in ("看病买药", "盒马鲜生", "盒马生鲜", "超市便利", "美食外卖",
- "到店团购", "天天红包", "赚吃货豆", "数码潮玩") if m in joined)
- return hits >= 3
- def _look_like_shop_page(raw: list) -> bool:
- """OCR复核是否店铺页(防AI把店铺页误判成列表页):
- ① 「刚刚搜过」+「评价」同时在(店铺页特有元素)
- ② 店铺名特征块只有1个且在屏幕上部25%(列表页会有多个店铺名散布全屏)
- 店铺名特征 = 含"快递发"后缀或 药房/药店/医药/连锁/自营/旗舰 等商家关键词
- (海王星辰等不含"药房"的店名靠"快递发"后缀兜住)"""
- texts = [r.get("text", "") for r in raw]
- joined = "".join(texts)
- if ("刚刚搜过" in joined) and ("评价" in joined):
- return True
- kw = re.compile(r'快递发|药房|药店|医药|商城|超市|自营|连锁|旗舰')
- ys = [(r.get("box") or [0, 0, 0, 0])[1] for r in raw if kw.search(r.get("text", ""))]
- if len(ys) == 1:
- max_y = max(((r.get("box") or [0, 0, 0, 0])[3] for r in raw), default=0)
- return ys[0] < max_y * 0.25
- return False
- 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"])
- human_sleep(3)
- return True
- except Exception as e:
- print(f" ⚠ 重新加载处理异常: {e}")
- return False
- # 弹窗找不到关闭按钮时的重启恢复:仅限 step2 搜索流程内,最多重启3次
- _POPUP_RESTART_COUNT = 0
- MAX_POPUP_RESTART = 3
- 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
- # 全屏活动页特征词(红包/签到类弹窗无遮罩变暗,遮罩检测漏检;命中即back关闭)
- ACTIVITY_KW = ("红包", "领取", "金币", "翻牌", "开宝箱", "签到", "逛一逛")
- def _close_ad_popup(ex: SafeExecutor, force: bool = False) -> bool:
- """
- 检测并关闭广告弹窗:
- ① force=True: 跳过遮罩门槛,OCR找活动页特征词(红包/领取/金币等全屏活动页——
- 这类弹窗背景不变暗,遮罩检测会漏检),命中则 back 关闭
- ② 遮罩检测(弹窗周围变暗,本地计算零成本,最快最准)
- ③ AI 找关闭按钮(云端OCR → AI判断)
- ④ 模板匹配打叉兜底
- ⑤ back键关闭(activity对话框弹窗back即可关)
- ⑥ 都失败 → 重启App恢复,仅限搜索流程内3次
- """
- try:
- shot = _shot_path(ex, "ad_check.png")
- ex.driver.screenshot(shot)
- if cv2.imread(shot) is None:
- ex.driver.screenshot(shot)
- # 0. force: 活动页特征词检测(无遮罩门槛)
- if force:
- texts = [r.get("text", "") for r in OCR.recognize(shot, detail="all")]
- # 列表页标记在场(筛选/快递) → 我们就在搜索列表页上, 页面里的"红包"字样
- # 是页签/促销文案, 不是弹窗 —— 绝不能back(back会退出列表页)
- on_list = any(("筛选" in t or "快递" in t) for t in texts)
- hit = [w for w in ACTIVITY_KW if any(w in t for t in texts)]
- if hit and not on_list:
- print(f" 检测到活动页弹窗(特征词:{hit[0]},无列表标记),back关闭")
- ex.driver.press("back")
- human_sleep(1.2)
- return True
- if hit and on_list:
- print(f" 页面含活动词({hit[0]})但列表标记在场 → 是列表页不是弹窗,不处理")
- # 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]))
- human_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"])
- human_sleep(1.5)
- return True
- # 3.5 back键关闭(红包/活动类弹窗多是activity对话框,back即可关;关不掉无损继续)
- ex.driver.press("back")
- human_sleep(1.2)
- ex.driver.screenshot(shot)
- if cv2.imread(shot) is not None and not _has_popup_mask(shot):
- print(" 弹窗已通过back键关闭")
- return True
- # 4. AI/模板/back都关不掉(图片型弹窗)→ 重启App恢复,仅限搜索流程内3次
- global _POPUP_RESTART_COUNT
- _POPUP_RESTART_COUNT += 1
- if _POPUP_RESTART_COUNT > MAX_POPUP_RESTART:
- print(f" ⚠ 弹窗重启恢复已达{MAX_POPUP_RESTART}次上限,不再重启")
- return True
- print(f" ⚠ 弹窗关不掉(红包类图片弹窗),重启App恢复(第{_POPUP_RESTART_COUNT}/{MAX_POPUP_RESTART}次)")
- try:
- step1_open_app(ex)
- except Exception as e:
- print(f" ⚠ 重启App异常: {e}")
- return True
- except Exception as e:
- print(f" ⚠ 广告弹窗处理异常: {e}")
- return False
- def _swipe_side_two_segment(ex: SafeExecutor, w: int, h: int, distance: int, zone: dict):
- """两段式侧滑: 拇指锚点±30px起指, 拆两笔独立手势(55~65%+剩余), 笔间自然停顿。
- 每笔TouchPipe→motionevent降级; 全部完成后像素验证, 未生效退adb三段式兜底"""
- import subprocess as _sp
- import random as _random
- d1 = int(distance * _random.uniform(0.55, 0.65))
- d2 = distance - d1
- anchor = (int(w * _random.uniform(*zone["x"])), int(h * _random.uniform(*zone["y"])))
- dmax = max(d1, d2)
- zs = -1 if zone["drift"][0] > 0 else 1 # 右手凸左"(" / 左手凸右")"
- chk = _shot_path(ex, "swipe_check.png")
- ex.driver.screenshot(chk)
- img = cv2.imread(chk)
- before = cv2.resize(img, (90, 200)) if img is not None else None
- segs = ((d1, 0.15 + 0.10 * (1 - d1 / dmax)), (d2, 0.15 + 0.10 * (1 - d2 / dmax)))
- for i, (d, ratio) in enumerate(segs):
- pts = wrist_arc_pts_side(w, h, d, zone, anchor=anchor, bow_ratio=zs * ratio, n=40)
- if not touchpipe_drag(ex.driver, pts, step=_random.uniform(0.006, 0.010),
- hold=_random.uniform(0.06, 0.12), tail=_random.uniform(0.02, 0.05)):
- print(f" [滑动] 两段式第{i+1}笔TouchPipe未生效,降级motionevent")
- pts10 = wrist_arc_pts_side(w, h, d, zone, anchor=anchor, bow_ratio=zs * ratio, n=10)
- motionevent_drag(ex.device_id, pts10)
- if i == 0:
- human_sleep(_random.uniform(0.35, 0.6)) # 抬指自然停顿
- time.sleep(1.0)
- ex.driver.screenshot(chk)
- img = cv2.imread(chk)
- after = cv2.resize(img, (90, 200)) if img is not None else None
- if (before is not None and after is not None
- and float(np.mean(cv2.absdiff(before, after))) <= 2.0):
- print(" [滑动] 两段式未生效,退回adb三段式兜底")
- swipe_x = w // 2
- seg_px = distance // 3
- for k in range(3):
- s = int(h * 0.8) - k * 80
- e = max(50, s - seg_px)
- _sp.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(0.6)
- def _adb_swipe_up(ex: SafeExecutor, distance: int):
- """列表上滑策略分发(每次滑动随机): 80%右手两段式 / 10%左手两段式 / 10%原单笔。
- 两段式 = 拇指锚点起指 + 两笔独立"("弧线 + 笔间停顿(用户实机认可);
- 单笔 = 旧逻辑保留(三级降级+逐级像素验证)"""
- import random as _random
- w, h = ex.driver.window_size()
- r = _random.random()
- if r < SWIPE_RIGHT_TWO_P:
- _swipe_side_two_segment(ex, w, h, distance, RIGHT_SIDE_ZONE)
- elif r < SWIPE_RIGHT_TWO_P + SWIPE_LEFT_TWO_P:
- _swipe_side_two_segment(ex, w, h, distance, LEFT_SIDE_ZONE)
- else:
- _swipe_single_old(ex, distance)
- def _swipe_single_old(ex: SafeExecutor, distance: int):
- """原单笔滑动(旧起点带), 三级降级(每级滑动后像素验证, 未生效自动降下一级):
- 1) TouchPipe弧线(60点, 100Hz级, 丝滑+弧线; 同验证码滑动通道)
- 2) motionevent弧线(10点, ~45ms/点, 略步进)
- 3) 三段式adb swipe(兜底)"""
- import subprocess as _sp
- w, h = ex.driver.window_size()
- chk = _shot_path(ex, "swipe_check.png")
- ex.driver.screenshot(chk)
- img = cv2.imread(chk)
- before = cv2.resize(img, (90, 200)) if img is not None else None
- def _page_moved():
- ex.driver.screenshot(chk)
- img = cv2.imread(chk)
- after = cv2.resize(img, (90, 200)) if img is not None else None
- return (before is not None and after is not None
- and float(np.mean(cv2.absdiff(before, after))) > 2.0)
- # 1) TouchPipe 弧线(最丝滑)
- try:
- pts = wrist_arc_pts(w, h, distance, n=60)
- if touchpipe_drag(ex.driver, pts):
- time.sleep(1.0)
- if _page_moved():
- return
- print(" [滑动] TouchPipe未生效,降级motionevent")
- except Exception as e:
- print(f" [滑动] TouchPipe异常({e}),降级motionevent")
- # 2) motionevent 弧线
- try:
- pts10 = wrist_arc_pts(w, h, distance, n=10)
- motionevent_drag(ex.device_id, pts10)
- time.sleep(1.0)
- if _page_moved():
- return
- print(" [滑动] motionevent未生效,退回adb swipe")
- except Exception as e:
- print(f" [滑动] motionevent异常({e}),退回adb swipe")
- # 3) 兜底: 三段式 adb swipe
- swipe_x = w // 2
- seg_px = distance // 3
- for i in range(3):
- s = int(h * 0.8) - i * 80
- e = max(50, s - seg_px)
- _sp.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(0.6)
- def _adb_swipe_left(ex: SafeExecutor, y_ratio: float = 0.3):
- """从右往左滑(拟人弧线):切换商品图片轮播; TouchPipe→motionevent→adb。
- 带滑动验证: 轮播没切换(顶部区域像素无变化)→换触点高度/加大距离重试,最多2次。
- 手势极快(20点/2~4ms步进, 整笔~0.15s), 快甩才带得动轮播。
- 返回 True=确认切换过, False=两次都未生效(可能本商品无轮播)"""
- import random as _random
- import subprocess
- w, h = ex.driver.window_size()
- chk = _shot_path(ex, "left_check.png")
- def _region_moved(before):
- """顶部42%区域(商品图区)与基线对比是否变化"""
- ex.driver.screenshot(chk)
- img = cv2.imread(chk)
- if img is None or before is None:
- return True # 截图不可用时按生效处理,避免无谓重试
- a = cv2.resize(img[0:int(h * 0.42), :], (90, 60))
- b = cv2.resize(before[0:int(h * 0.42), :], (90, 60))
- return float(np.mean(cv2.absdiff(b, a))) > 3.0
- ex.driver.screenshot(chk)
- base = cv2.imread(chk)
- tried = []
- for i in range(2):
- yr = y_ratio + (0.0, -0.07)[i] # 轮播图高度因商品而异,换高度重试
- dist = int(w * (0.65 + 0.08 * i) * _random.uniform(0.95, 1.05))
- try:
- pts = wrist_arc_pts_h(w, h, dist, y_ratio=yr, n=20, drift_range=(30, 70))
- if touchpipe_drag(ex.driver, pts, step=_random.uniform(0.002, 0.004),
- hold=_random.uniform(0.015, 0.035),
- tail=_random.uniform(0.01, 0.025)):
- tried.append(f"tp@{yr:.2f}")
- else:
- print(" [图片左滑] TouchPipe未生效,降级motionevent")
- pts10 = wrist_arc_pts_h(w, h, dist, y_ratio=yr, n=10, drift_range=(30, 70))
- motionevent_drag(ex.device_id, pts10, step=_random.uniform(0.015, 0.022))
- tried.append(f"me@{yr:.2f}")
- except Exception as e:
- print(f" [图片左滑] 弧线异常({e}),退回adb swipe")
- subprocess.run(
- ["adb", "-s", ex.device_id, "shell", "input", "swipe",
- str(int(w * 0.85)), str(int(h * yr)), str(int(w * 0.15)), str(int(h * yr)), "300"],
- capture_output=True, timeout=10
- )
- tried.append(f"adb@{yr:.2f}")
- human_sleep(1.0)
- if _region_moved(base):
- print(f" [图片左滑] 轮播已切换 ({', '.join(tried)})")
- human_sleep(0.4)
- return True
- print(f" [图片左滑] 2次均未切换,可能本商品无轮播 ({', '.join(tried)})")
- return False
- def _adb_swipe_up_short(ex: SafeExecutor):
- """上滑半屏(拟人弧线):说明书页内容可能需滑动; TouchPipe→motionevent→adb 三级降级"""
- import subprocess
- w, h = ex.driver.window_size()
- try:
- pts = wrist_arc_pts(w, h, int(h * 0.4), n=40, drift_range=(40, 90))
- if touchpipe_drag(ex.driver, pts):
- human_sleep(0.9)
- return
- print(" [说明书上滑] TouchPipe未生效,降级motionevent")
- pts10 = wrist_arc_pts(w, h, int(h * 0.4), n=8, drift_range=(40, 90))
- motionevent_drag(ex.device_id, pts10)
- human_sleep(0.9)
- return
- except Exception as e:
- print(f" [说明书上滑] 弧线异常({e}),退回adb swipe")
- 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
- )
- human_sleep(1)
- # ── 店铺反馈弹窗(滑动被误判长按店铺名)检测与关闭 ──
- FEEDBACK_POPUP_KW = ("店铺问题", "不喜欢该店铺", "与搜索词无关",
- "图片不够真实", "商品品质不好", "价格太贵", "疑似情色")
- def _close_feedback_popup(ex: SafeExecutor) -> bool:
- """检测并关闭"店铺问题/商品问题"反馈弹窗(滑动被误判长按所致)。
- 关闭方式: 点弹窗上方暗区,或右上角×(back 会退回搜索页,不能用)。
- 返回 True=检测到弹窗(无论是否成功关闭), False=没弹窗。"""
- try:
- shot = _shot_path(ex, "ad_feedback_popup.png")
- ex.driver.screenshot(shot)
- blocks = OCR.recognize(shot, detail="all")
- texts = [b.get("text", "") for b in blocks]
- if not any(kw in t for t in texts for kw in FEEDBACK_POPUP_KW):
- return False
- print("[step3] 检测到店铺反馈弹窗(滑动误触长按),关闭中")
- w, h = ex.driver.window_size()
- anchor_y = 0
- for b in blocks:
- if "店铺问题" in b.get("text", ""):
- anchor_y = (b["box"][1] + b["box"][3]) // 2
- break
- # 1) 点弹窗上方暗区(大目标); 2) 点×(与"店铺问题"同行右侧); 3) ×实测兜底位
- taps = [(int(w * 0.5), int(h * 0.25))]
- if anchor_y:
- taps.append((int(w * 0.84), anchor_y))
- taps.append((int(w * 0.84), int(h * 0.52)))
- for tx, ty in taps:
- ex.tap(tx, ty)
- time.sleep(1.2)
- ex.driver.screenshot(shot)
- blocks = OCR.recognize(shot, detail="all")
- texts = [b.get("text", "") for b in blocks]
- if not any(kw in t for t in texts for kw in FEEDBACK_POPUP_KW):
- print("[step3] 反馈弹窗已关闭")
- return True
- print("[step3] 反馈弹窗关闭未生效,后续批次可能空转")
- return True
- except Exception as e:
- print(f"[step3] 反馈弹窗检测异常: {e}")
- return False
- def _swipe_next_batch(ex: SafeExecutor, distance: int):
- """列表上滑统一入口: 滑动后检查长按误触的反馈弹窗。
- 弹窗会骗过滑动的像素diff验证且挡住列表(后续批次全是旧卡片) → 关掉后补滑一次。"""
- _adb_swipe_up(ex, distance)
- if _close_feedback_popup(ex):
- _adb_swipe_up(ex, distance)
- 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")
- human_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")
- human_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"])
- human_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"])
- human_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")
- human_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")
- human_sleep(1.2)
- return url
- except Exception as e:
- print(f" ⚠ 快照采集异常: {e}")
- return ""
- def _wait_for_any_text(ex: SafeExecutor, targets: list, max_s: float, prefix: str = "wait_text") -> bool:
- """轮询截图(本地OCR)等待任一目标文字出现——等页面加载完成再进行下一步。
- 返回 True=等到了, False=超时未出现(调用方自行决定是否继续)"""
- import time as _t
- deadline = _t.time() + max_s
- while _t.time() < deadline:
- shot = _shot_path(ex, f"{prefix}.png")
- ex.driver.screenshot(shot)
- if cv2.imread(shot) is not None:
- texts = [r.get("text", "") for r in OCR.recognize(shot, detail="all")]
- if any(tg in t for tg in targets for t in texts):
- return True
- _t.sleep(1.2)
- return False
- 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"])
- human_sleep(1.2)
- # 等店铺信息页加载出「查看营业资质」(最多6s,防止没等加载就找)
- _wait_for_any_text(ex, ["查看营业资质"], 6, "lic_wait")
- # 下半区找「查看营业资质」,找不到先上滑一次再看
- 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")
- human_sleep(1.5)
- else:
- print(" ⚠ 资质采集跳过:未找到店铺名或「查看营业资质」")
- return result
- print(f" 查看营业资质: ({lic_btn['x']}, {lic_btn['y']})")
- ex.tap(lic_btn["x"], lic_btn["y"])
- human_sleep(1.5)
- _ai_check_captcha(ex) # 资质页可能出现验证码(无关键词检查环节)
- # 4. 等资质页加载出「资质编号」(最多8s;刚点完就截图经常是加载中的页面)
- _wait_for_any_text(ex, ["资质编号", "营业执照"], 8, "lic_no_wait")
- 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:
- # 页面可能仍在加载/云端OCR波动:等2秒重拍再试一次
- time.sleep(2)
- ex.driver.screenshot(shot3)
- raw3 = OCR.recognize(shot3, detail="all", engine="cloud")
- license_no = extract_value_after(raw3, "资质编号")
- if license_no:
- 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))
- human_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原始结果带坐标, 页面状态)
- 页面状态: "ok"=正常(含空批次) | "page_wrong"=AI判定不在药品列表页
- | "no_more"=检出「搜索结果较少」底部标志(结果耗尽,本屏真卡仍会返回)"""
- shot = _screenshot(ex, shot_name)
- raw_full = OCR.recognize(shot, detail="all")
- raw = raw_full
- w, h = ex.driver.window_size()
- # 底部耗尽标志(必须用裁剪前的全屏OCR检测,防右列过滤把横跨全屏的标志行滤掉):
- # 「搜索结果较少,为你推荐相关店铺」= 本药品搜索结果已耗尽
- no_more = any(("搜索结果较少" in r.get("text", "")) or ("为你推荐相关店铺" in r.get("text", ""))
- for r in raw_full)
- # 只保留右列(商品描述列):用¥定位分界,过滤左侧图片文字(包装字/英文/乱码)
- 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
- # 视觉方案(唯一路径):PP-OCR识别 → GLM分卡 → 几何校验;GLM失败时内部有文本AI兜底
- shops, vstatus = VisionParser().parse_shops(shot, screen_size=(w, h), keyword=keyword, crop_x=split_x or 0)
- if vstatus == "page_wrong":
- if no_more:
- # 底部耗尽标志在场说明仍在列表页(下方只是店铺推荐流),忽略page_wrong误判
- print("[step3] 底部「搜索结果较少」标志在场,忽略page_wrong")
- else:
- # GLM判定当前页面不是药品列表(back退过头/走错频道)→ 不提取,交给上层走恢复流程
- return [], raw, "page_wrong"
- if vstatus == "failed":
- if no_more:
- # 耗尽标志在场但GLM失败: 不走文本AI兜底(防把底部推荐店铺采进库),按耗尽收尾
- print("[step3] 底部耗尽标志在场且GLM失败,跳过文本AI兜底")
- return [], raw, "no_more"
- # GLM真失败才fallback到文本AI提取
- print("[step3] GLM失败,fallback 到 OCR+文本AI")
- shops = AIParser().parse_shops(raw, screen_size=(w, h), keyword=keyword)
- if not shops:
- # fallback也没提到 → 让文本AI判断当前页面再决定
- ptype = AIParser().check_page(raw).get("type", "unknown")
- if ptype == "list":
- print("[step3] 文本AI判定在列表页(本轮无有效卡片,走空批次滑动)")
- return [], raw, ("no_more" if no_more else "ok")
- print(f"[step3] 文本AI判定页面类型: {ptype}(非列表页)")
- return [], raw, "page_wrong"
- # 只保留有效店铺名+价格:店铺名必须含中文或字母(排除纯数字/标点/空格)
- 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, ("no_more" if no_more else "ok")
- 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 _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,
- batch_no: int = None) -> dict:
- """
- 点击进入店铺 → step4 → 说明书 → 快照 → 资质 → 返回完整数据 dict
- task: 调度任务 dict(task_id/enterprise_id/collect_round等),手动模式传 None
- batch_no: 当前列表页批次号(Excel来源截图追溯用)
- """
- """点击进入店铺 → 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 == "__SKIP__":
- # 连续5次进入页面均未加载(网络/加载异常):退回列表页,跳过本商品
- # step3会对"连续2个商品都跳过"升级为白屏分级恢复(冷却→重启→超限重派)
- print(f" ⛔ 页面多次未加载{'(白屏)' if _LAST_SKIP_WHITE else ''},跳过本商品: {shop_name} | {product_title[:30]}")
- for _ in range(5):
- try:
- pos = _where_am_i(ex)
- except Exception:
- break
- if pos in ("list", "home"):
- break
- ex.driver.press("back")
- human_sleep(1.4)
- return {"__skip__": 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" ⛔ 未获取到链接,本店数据不入库: {shop_name} | {product_title[:30]}")
- for _ in range(5):
- try:
- pos = _where_am_i(ex)
- except Exception:
- break
- if pos in ("list", "home"):
- break
- ex.driver.press("back")
- human_sleep(1.4)
- return {}
- # 内存熔断:本地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")
- human_sleep(1.4)
- # Excel 备份:一个药品一个sheet,两个店铺名对比(列表页OCR vs 详情页AI提取)
- # 来源截图 = 设备/批次,供 audit_ocr.py 事后核对OCR识别准确率
- try:
- import excel_log
- source = f"{ex.device_id}/step3_b{batch_no}" if batch_no is not None else ""
- excel_log.append_row(product_title, price, shop_name, AI_SHOP_NAME,
- snapshot_url, qr_url or "", sheet_name=keyword, source=source)
- except Exception as _e:
- print(f" ⚠ Excel备份失败: {_e}")
- 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}次,进入风控可能封号"
- err_log.log_error(ex.device_id, "captcha_daily_limit", message=CAPTCHA_ABORT_REASON,
- extra={"today_count": today_count, "task_id": _TASK_ID})
- 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 yzm.nine_grid import solve as solve_nine
- ok = solve_nine(ex.driver)
- else:
- try:
- from yzm.tmp_captcha_test7 import solve_slider # 2026-09-23 全面真人化版(轨迹/速度/停顿/微调)
- except ImportError:
- from yzm.tmp_captcha_test6 import solve_slider # 旧版兜底(生产机未同步test7时)
- ok = solve_slider(ex.driver)
- if ok:
- print(f" ✅ 验证码已解决")
- solved = True
- break
- print(f" ❌ 第{attempt}次失败")
- human_sleep(1)
- if solved:
- # 只有解决成功才计数 + 一天≥8次停止检查 + 每2次休息30分钟
- aborted = _log_captcha(ex)
- if not aborted:
- _captcha_rest(ex)
- else:
- print(f" ⚠ 5次自动处理失败, 请人工处理...(不计数)")
- err_log.log_error(ex.device_id, "captcha_solve_failed",
- message="验证码连续5次自动处理失败,转人工(input阻塞前留档)")
- input(" 处理完成后按回车继续...")
- return True
- def _chunked_sleep_with_report(total_seconds: float, label: str) -> bool:
- """分段睡眠并每10分钟回告一次进度(防后台判假死)。
- 返回 False = 休息期间收到调度限额/错误(应停止采集)"""
- rest_left = total_seconds
- while rest_left > 0:
- if _SCHEDULER is not None and getattr(_SCHEDULER, "limit_reached", False):
- print(f" ⏸ {label}休息中收到调度限额/错误,提前结束休息并停止采集")
- return False
- chunk = min(rest_left, 600) # 每10分钟一段
- time.sleep(chunk)
- rest_left -= chunk
- 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}")
- return True
- def _captcha_rest(ex: SafeExecutor) -> None:
- """每次验证码解决后:休息 CAPTCHA_REST_MINUTES~MAX 分钟(随机)"""
- import random as _random
- today_count = _captcha_count_today(ex.device_id)
- if today_count >= CAPTCHA_DAILY_LIMIT:
- return
- rest_minutes = _random.randint(CAPTCHA_REST_MINUTES, CAPTCHA_REST_MAX_MINUTES)
- print(f" ⏸ 第{today_count}次验证码,休息{rest_minutes}分钟({CAPTCHA_REST_MINUTES}~{CAPTCHA_REST_MAX_MINUTES}随机)...")
- _chunked_sleep_with_report(rest_minutes * 60, "验证码")
- # 验证码强特征词(店铺页/列表页文字多但无这些词——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])
- human_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(" ⚠ 检测到账号被踢/封号(登录页),停止采集")
- err_log.log_error(ex.device_id, "account_kicked", message="检测到登录页元素,账号被踢/封号")
- 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 或空字符串
- """
- global AI_SHOP_NAME, _LAST_SKIP_WHITE
- AI_SHOP_NAME = "" # 每次进店重置,避免上一家的AI店名残留
- _LAST_SKIP_WHITE = False
- # 安全的文件名前缀(用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}")
- human_sleep(6)
- # ── 检测页面类型:验证码/风控/正常 ──
- # unknown恢复流程:等1秒二次截图确认 → 每确认一次unknown就back一层+重新点击进入(网络没加载就刷新)
- # → 最多试UNKNOWN_REENTER_LIMIT次加载,全失败返回__SKIP__(跳过本商品,由step3决定是否升级重启)
- unknown_round = 0
- for page_retry in range(16):
- 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":
- # OCR多特征复核:店铺页自带「首页/商品/评价」页签会骗过AI(实测阿里健康误判)。
- # 真首页特征 = 「AI点外卖」导航 或 频道宫格≥3个;「刚刚搜过」在场必非首页
- if not _looks_like_app_home(check_raw):
- if _look_like_shop_page(check_raw):
- print(" AI判home但OCR复核为店铺页('首页'是页签字样),按进店继续")
- break
- print(" AI判home但OCR复核非首页(无AI点外卖/频道宫格≥3),继续检测")
- continue
- # 二次确认:截图太早页面没加载完时AI可能误判首页(底部导航"我的"在任何页面都可见)
- human_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":
- _save_evidence(shot_check, "restart_home1")
- print(" ⚠ 二次确认仍为首页(被踢回/退过头),触发重启恢复")
- return "__RESTART__"
- if ptype2 == "normal":
- break # 页面加载完成,恢复正常
- print(f" AI先判home,二次确认为{ptype2},继续检测")
- continue
- if ptype == "list":
- # 第一层:OCR复核防AI误判(店铺页只有1个店铺名且在屏幕上方,AI有时把店内商品列表看成列表页)
- if _look_like_shop_page(check_raw):
- print(" AI判list但OCR复核为店铺页(单店名且在顶部),按进店继续")
- break
- # 第二层:可能是点击后跳转未完成(残影还是列表页),等1秒重拍让AI再判一次
- human_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 != "list":
- print(f" 二次确认为{ptype2}(跳转完成),继续检测")
- continue
- print(" ⚠ 二次确认仍在列表页(未成功进店),放弃本店")
- return ""
- if ptype == "login":
- global ACCOUNT_ABORTED
- ACCOUNT_ABORTED = True
- err_log.log_error(ex.device_id, "account_kicked", message="AI检测到登录页,账号被踢/封号")
- print(" ⚠ AI检测到登录页(账号被踢/封号),停止采集")
- return "__TERMINATE__"
- if ptype == "normal":
- _ai_shop = str(page_type.get("shop") or "").strip()
- if _ai_shop:
- AI_SHOP_NAME = _ai_shop # 详情页AI提取的店铺名(Excel对比用)
- break # 正常,跳出重试循环
- # qrcode/unknown:等1秒二次截图确认(点击后立即截图可能页面没加载完,避免误判)
- human_sleep(1)
- ex.driver.screenshot(shot_check)
- confirm_raw = OCR.recognize(shot_check, detail="all")
- confirm_page = AIParser().check_page(confirm_raw)
- ptype2 = confirm_page.get("type", "unknown")
- if ptype2 == "normal":
- _ai_shop = str(confirm_page.get("shop") or "").strip()
- if _ai_shop:
- AI_SHOP_NAME = _ai_shop # 详情页AI提取的店铺名(Excel对比用)
- 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":
- # 同上:AI判home先OCR多特征复核,防店铺页「首页」页签误触发重启
- if not _looks_like_app_home(confirm_raw):
- if _look_like_shop_page(confirm_raw):
- print(" AI二次判home但OCR复核为店铺页,按进店继续")
- break
- print(" AI二次判home但OCR复核非首页,继续检测")
- continue
- _save_evidence(shot_check, "restart_home2")
- print(" ⚠ 二次确认检测到首页,触发重启恢复")
- return "__RESTART__"
- if ptype2 == "list":
- # OCR复核防AI误判(同第一处list分支)
- if _look_like_shop_page(confirm_raw):
- print(" AI二次确认判list但OCR复核为店铺页,按进店继续")
- break
- print(" ⚠ 二次确认检测到列表页(未成功进店),放弃本店")
- return ""
- if ptype2 == "login":
- ACCOUNT_ABORTED = True # 本函数已声明global
- err_log.log_error(ex.device_id, "account_kicked", message="二次确认检测到登录页,账号被踢/封号")
- print(" ⚠ 二次确认检测到登录页(账号被踢/封号),停止采集")
- return "__TERMINATE__"
- # 两次都是unknown → 立即back一层+重新点击进入(每确认一次就刷新,不攒次数)
- unknown_round += 1
- _ws = _is_white_screen(shot_check)
- if unknown_round >= UNKNOWN_REENTER_LIMIT:
- # 5次加载尝试全失败:保存现场截图,跳过本商品(不重启,由step3处理连续跳过)
- 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" ⚠ 连续{UNKNOWN_REENTER_LIMIT}次进入页面均未加载({'白屏' if _ws else '其他异常'}),跳过本商品")
- _LAST_SKIP_WHITE = _ws
- return "__SKIP__"
- print(f" 确认unknown(第{unknown_round}次,{'白屏' if _ws else '非白屏'}),back一层并重新点击进入")
- ex.driver.press("back")
- human_sleep(1.5)
- if shop_xy:
- ex.tap(shop_xy[0], shop_xy[1])
- human_sleep(2.5)
- human_sleep(2)
- else:
- # 检测循环耗尽仍未正常 → 同样按跳过处理(不重启)
- import shutil
- _ws = _is_white_screen(shot_check)
- 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},{'白屏' if _ws else '其他异常'}),跳过本商品")
- _LAST_SKIP_WHITE = _ws
- return "__SKIP__"
- # 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
- human_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秒后重试")
- human_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)
- human_sleep(3) # 点击后先等页面响应(验证码处理后/网络慢时切换慢)
- # 检测是否进入商品详情页(验证码检测优先——验证码页文字块少,不能被"加载中"分支挡掉)
- notready_streak = 0 # 连续"未就绪"轮次(白屏时OCR只剩状态栏1~3块文字)
- for _ in range(8):
- human_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)
- human_sleep(2)
- break
- continue
- # 2. 页面加载中/切换中(文字少且无验证码)→ 继续等待,不误判
- if len(detail_texts) <= 8:
- notready_streak += 1
- # 连续3轮仅状态栏级文字 → 大概率白屏(有图无字),立即跳过别盲等
- # (原逻辑只会"继续等待",3次尝试×8轮能空转1分半)
- if notready_streak >= 3 and _is_white_screen(detail_check):
- _save_evidence(detail_check, "white_skip_detail")
- _LAST_SKIP_WHITE = True
- print(" ⚠ 详情页白屏(连续多轮仅状态栏文字),跳过本商品")
- return "__SKIP__"
- print(f" 页面未就绪(仅{len(detail_texts)}块文字),继续等待...")
- continue
- # 2.5 检测是否退回首页/列表页(点标题失败/back过头时常见)→ 立即处理,不盲等
- joined_texts = "".join(detail_texts)
- if ("看病买药" in joined_texts) and ("我的" in joined_texts):
- _save_evidence(detail_check, "restart_home_kw")
- 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":
- # OCR多特征复核(与前两处守卫一致):店铺页「首页」页签/左侧分类栏会骗过AI
- if not _looks_like_app_home(ai_raw):
- print(" AI判home但OCR复核非首页(无AI点外卖/频道宫格≥3),放弃本店")
- return ""
- _save_evidence(last_shot, "restart_home_ai")
- print(" ⚠ AI检测到首页,触发重启恢复")
- return "__RESTART__"
- if ai_page.get("type") == "list":
- print(" ⚠ AI检测到列表页,放弃本店")
- return ""
- if ai_page.get("type") == "login":
- ACCOUNT_ABORTED = True # 本函数已声明global
- err_log.log_error(ex.device_id, "account_kicked", message="进详情失败后AI检测到登录页,账号被踢/封号")
- 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):
- human_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 _recover_to_list(ex: SafeExecutor, brand_keyword: str,
- h: int, total_scroll_px: int) -> str:
- """
- step3页面异常分级恢复:识别当前页 → 用最小代价回到搜索结果列表页。
- 返回: list=本来就在列表(误报) back=back退回(列表位置不变,无需滑回)
- research=重新搜索 restart=重启App fail=仍无法确认在列表页
- """
- def _classify() -> str:
- """当前页分类: list/home/page/risk/login/unknown(OCR先查列表页特征「筛选」,其余交给AI)"""
- shot = _shot_path(ex, "recover_pos.png")
- ex.driver.screenshot(shot)
- if cv2.imread(shot) is None:
- ex.driver.screenshot(shot)
- raw = OCR.recognize(shot, detail="all")
- # 药品列表页特征 = 「筛选」+「快递」并排(与step2搜索成功判据一致)。
- # 外卖首页也有「筛选」,单看筛选会把首页误判成列表 → 恢复动作=list → 死循环
- _joined = "".join(r["text"] for r in raw)
- if ("筛选" in _joined) and ("快递" in _joined):
- return "list"
- ptype = str(AIParser().check_page(raw).get("type") or "unknown")
- if ptype in ("shop", "detail", "normal", "qrcode"):
- return "page"
- if ptype in ("home", "risk", "login"):
- return ptype
- return "unknown" # 含AI判list但OCR无「筛选」的可疑情况 → 走重启兜底最稳
- def _scroll_back():
- # 从列表顶部滑动恢复到上次采集位置
- remain = total_scroll_px
- while remain > 0:
- step = min(remain, int(h * 0.5))
- _swipe_next_batch(ex, step)
- remain -= step
- human_sleep(2)
- try:
- page = _classify()
- if page == "list":
- return "list"
- if page == "login":
- # 登录页:账号可能被踢,循环顶部 _check_account_kicked 会统一处理
- return "fail"
- if page == "risk":
- # 风控弹窗 → 先走验证码处理再看
- _ai_check_captcha(ex)
- page = _classify()
- if page == "list":
- return "back"
- if page == "page":
- # 认得出是店铺/详情/二维码页 → back退回(列表滚动位置不丢)
- for _ in range(3):
- ex.driver.press("back")
- human_sleep(1.4)
- page = _classify()
- if page == "list":
- return "back"
- if page != "page":
- break
- if page == "home":
- # 在App首页 → 只重新搜索,不重启App
- if not step2_search(ex, brand_keyword):
- return "fail"
- _scroll_back()
- return "research" if _classify() == "list" else "fail"
- if page == "login":
- return "fail"
- # unknown → 认不出在哪 → 重启App
- step1_open_app(ex)
- if not step2_search(ex, brand_keyword):
- return "fail"
- _scroll_back()
- return "restart" if _classify() == "list" else "fail"
- except Exception as e:
- print(f"[step3] 页面恢复异常: {e}")
- return "fail"
- 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 WHITE_SCREEN_REASSIGN, WHITE_SCREEN_REASSIGN_REASON, _LAST_SKIP_WHITE
- global _SCHEDULER, _TASK_ID, _CRAWLED_COUNT, _ROW_REST_MARK
- _CLOSE_BTN_CACHE = None
- CAPTCHA_ABORTED = False
- CAPTCHA_ABORT_REASON = ""
- ACCOUNT_ABORTED = False
- WHITE_SCREEN_REASSIGN = False
- WHITE_SCREEN_REASSIGN_REASON = ""
- _LAST_SKIP_WHITE = False
- CURRENT_PAGE = 0
- _SCHEDULER = scheduler
- _TASK_ID = (task or {}).get("task_id")
- _CRAWLED_COUNT = 0
- _ROW_REST_MARK = 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):
- _swipe_next_batch(ex, int(h * 0.7))
- human_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))
- _swipe_next_batch(ex, step)
- remain -= step
- human_sleep(2)
- else:
- visited = set()
- white_restart_count = 0 # 白屏重启恢复累计(>=MAX_WHITE_RESTART → 置重派标志)
- ws_success_count = 0 # 连续成功采集计数(>=WHITE_SUCCESS_RESET_N → 白屏重启额度清零)
- unknown_skip_streak = 0 # 连续"进页失败跳过"计数(跨批次,采到正常商品清零;连续2→分级恢复)
- ab_recover_fail_streak = 0 # a/b页面恢复连续失败计数(只拉长恢复前冷却,不终止任务)
- _list_noop_streak = 0 # 恢复连续返回"list"但页面始终page_wrong的计数(分类误判保护)
- parse_fail_streak = 0 # 连续"批次截图/识别失败"计数(连续3→终止任务)
- while True:
- if CAPTCHA_ABORTED:
- print(f"[step3] {CAPTCHA_ABORT_REASON or chr(39)+chr(39)}停止采集")
- break
- if scheduler is not None and getattr(scheduler, "limit_reached", False):
- # 回告接口返回 code=error(平台限额/任务已释放)→ 立即停止采集
- print(f"[step3] 调度返回限额/错误({getattr(scheduler, 'limit_msg', '')}),停止采集")
- break
- _check_account_kicked(ex) # 每批检测账号是否被踢/封号(xpath检查,开销小)
- if ACCOUNT_ABORTED:
- print("[step3] 账号被踢/封号,停止采集")
- break
- try:
- named, raw_local, page_status = _get_named_shops(ex, f"step3_b{batch_no}.png", keyword)
- except Exception as e:
- parse_fail_streak += 1
- print(f"[step3] 批次{batch_no} 截图/识别异常({e}),连续{parse_fail_streak}/3")
- if parse_fail_streak >= 3:
- print("[step3] 连续3批截图/识别失败,终止任务")
- err_log.log_error(ex.device_id, "parse_fail_terminate",
- message="连续3批截图/识别失败,终止任务",
- extra={"task_id": _TASK_ID, "keyword": keyword})
- stopped = True
- break
- human_sleep(3)
- continue
- parse_fail_streak = 0
- ab_recover_fail_streak = 0 # 本批正常识别(在列表页),页面恢复失败计数清零
- feed_end = (page_status == "no_more") # 底部「搜索结果较少」标志 → 采完本屏真卡后正常结束
- if page_status == "page_wrong":
- if ab_recover_fail_streak >= 3:
- _cd = min(300 * (ab_recover_fail_streak - 2), 900)
- print(f"[step3] 页面恢复连续失败{ab_recover_fail_streak}次,先冷却{_cd}秒再试...")
- if not _chunked_sleep_with_report(_cd, "页面恢复冷却"):
- stopped = True
- break
- print(f"[step3] 当前不在列表页({page_status}),执行页面恢复")
- _save_progress(ex.device_id, keyword, visited, total_scroll_px, batch_no)
- _act = _recover_to_list(ex, (brand + keyword).strip() or keyword, h, total_scroll_px)
- print(f"[step3] 页面恢复动作={_act}")
- if _act == "fail":
- ab_recover_fail_streak += 1
- else:
- ab_recover_fail_streak = 0
- if _act == "list":
- _list_noop_streak += 1
- else:
- _list_noop_streak = 0
- if _list_noop_streak >= 2:
- # 恢复连续2次声称"本来就在列表页"但页面始终page_wrong
- # → 分类误判(如外卖首页也带「筛选」),强制重启App兜底,防死循环
- print(f"[step3] 恢复连续{_list_noop_streak}次返回list仍不在列表页,强制重启App恢复")
- _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:
- _st = min(remain, int(h * 0.5))
- _swipe_next_batch(ex, _st)
- remain -= _st
- human_sleep(2)
- _list_noop_streak = 0
- continue
- if _ai_check_captcha(ex): # 列表页每批检测验证码(风控弹窗可能出现在列表)
- human_sleep(1)
- named, raw_local, page_status = _get_named_shops(ex, f"step3_b{batch_no}.png", keyword) # 验证码处理后重新识别
- if page_status == "no_more":
- feed_end = True
- elif page_status != "ok":
- # 验证码处理后仍不在列表页:同样定向恢复(不计数、不终止任务)
- print(f"[step3] 验证码处理后仍不在列表页({page_status}),执行页面恢复")
- _save_progress(ex.device_id, keyword, visited, total_scroll_px, batch_no)
- _act = _recover_to_list(ex, (brand + keyword).strip() or keyword, h, total_scroll_px)
- print(f"[step3] 页面恢复动作={_act}")
- continue
- raw_new = [s for s in named if _shop_key(s) not in visited]
- # 品牌+药品名+规格过滤(测试OCR准确率时可关闭:ENABLE_TITLE_FILTER=False 直接采集全部结果)
- if not ENABLE_TITLE_FILTER:
- # 不过滤,直接采集全部结果;但推荐流检测始终生效:
- # 标题与「品牌和药品名都不相关」的卡片连续出现 = 搜索结果耗尽、列表进入推荐区 → 结束
- # (品牌或药名任一命中即算相关,避免同品牌兄弟产品误触发)
- new_ones = []
- for s in raw_new:
- t = normalize_match_text(str(s[1] or ""))
- hit = (_match_fuzzy(n_key, t) if n_key else False) or \
- (_match_fuzzy(n_brand, t) if n_brand else False)
- if hit:
- unrelated = 0
- new_ones.append(s)
- else:
- unrelated += 1
- print(f"[step3] 推荐: {str(s[1])[:30]} 与品牌/药品名均无关 (连续{unrelated}个)")
- if unrelated >= RECOMMEND_STREAK_END:
- print(f"[step3] 连续{unrelated}个无关商品,判定搜索结果已耗尽(推荐流),任务结束")
- err_log.log_error(ex.device_id, "recommend_feed_end",
- message=f"连续{unrelated}个无关商品,判定搜索结果耗尽",
- extra={"task_id": _TASK_ID, "keyword": keyword})
- stopped = True
- break
- 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/回贴原样(云端改字已废弃——曾有"温胃舒"被误改成"养胃舒"的错误采集)
- 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 getattr(scheduler, "limit_reached", False):
- # 回告返回 code=error(平台限额/任务已释放)→ 立即停止采集
- print(f"[step3] 回告返回限额/错误({getattr(scheduler, 'limit_msg', '')}),停止采集")
- break
- if not new_ones:
- if feed_end:
- print("[step3] 底部耗尽标志在场且本批无新卡 → 搜索结果已耗尽,正常结束")
- break
- empty_streak += 1
- # 连续无新店铺不再直接结束(终止由推荐流检测负责,这里只留防死循环保底)
- if empty_streak >= EMPTY_BATCH_SAFETY:
- print(f"[step3] 连续{empty_streak}批无新店铺且未进推荐流(疑似弹跳到底),保底结束")
- err_log.log_error(ex.device_id, "empty_batch_safety_stop",
- message=f"连续{empty_streak}批无新店铺且推荐流检测未触发,保底结束",
- extra={"task_id": _TASK_ID, "keyword": keyword})
- stopped = True
- break
- # 滑动后再试
- print(f"[step3] 滑动查看下一批(无新店铺连续{empty_streak}批)")
- if len(named) >= 1:
- target_y = named[-1][4]
- swipe_dist = max(target_y - int(h * 0.05), int(h * 0.3))
- else:
- swipe_dist = int(h * 0.3)
- _swipe_next_batch(ex, swipe_dist)
- total_scroll_px += swipe_dist
- human_sleep(1.1) # 滑动后停顿 0.8~1.4s(±30%抖动)
- batch_no += 1
- _save_progress(ex.device_id, keyword, visited, total_scroll_px, batch_no)
- continue
- empty_streak = 0 # 有新店铺,重置计数
- ws_recover_requested = False
- home_restart_requested = False
- for shop in new_ones:
- result = _visit_shop(ex, shop, visited, keyword, task, batch_no=batch_no)
- 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__"):
- home_restart_requested = True
- break
- if result and result.get("__skip__"):
- # 页面未加载跳过:单次只跳过换下一个商品;连续2个都跳过才升级分级恢复
- unknown_skip_streak += 1
- print(f"[step3] 商品进页失败已跳过(连续{unknown_skip_streak}/2)")
- if unknown_skip_streak >= 2:
- print("[step3] 连续2个商品进页失败,触发白屏分级恢复")
- ws_recover_requested = True
- break
- continue
- if result:
- unknown_skip_streak = 0 # 采到一个正常商品就清零
- ws_success_count += 1
- if ws_success_count >= WHITE_SUCCESS_RESET_N:
- if white_restart_count > 0:
- print(f"[step3] 连续成功{ws_success_count}个商品,白屏重启计数清零(额度恢复)")
- white_restart_count = 0
- ws_success_count = 0
- result["brand"] = brand
- result["product_specs"] = spec_raw
- all_results.append(result)
- save_record(result) # 每采完一个立即入库,中断不丢数据
- _CRAWLED_COUNT = len(all_results)
- # 每爬 ROW_REST_EVERY 条 → 休息 ROW_REST_MIN~MAX 分钟(随机),期间回告防假死
- if _CRAWLED_COUNT // ROW_REST_EVERY > _ROW_REST_MARK:
- _ROW_REST_MARK = _CRAWLED_COUNT // ROW_REST_EVERY
- import random as _random
- rest_min = _random.uniform(ROW_REST_MIN, ROW_REST_MAX)
- print(f" ⏸ 已爬{_CRAWLED_COUNT}条({ROW_REST_EVERY}条整点),休息{rest_min:.1f}分钟...")
- if not _chunked_sleep_with_report(rest_min * 60, "50条"):
- stopped = True
- break
- if ws_recover_requested:
- # 白屏恢复:冷却等待无效 → 直接关App → 休息60~120s → 重启重搜滑回(超限回告调度重派)
- white_restart_count += 1
- if white_restart_count >= MAX_WHITE_RESTART:
- WHITE_SCREEN_REASSIGN = True
- WHITE_SCREEN_REASSIGN_REASON = f"商品页反复白屏,关App重启恢复{white_restart_count}次无效"
- err_log.log_error(ex.device_id, "white_screen_reassign", message=WHITE_SCREEN_REASSIGN_REASON,
- extra={"task_id": _TASK_ID, "restarts": white_restart_count, "keyword": keyword})
- print(f"[step3] 白屏重启恢复超限({MAX_WHITE_RESTART}次),置重派标志,停止采集(进度保留)")
- stopped = True
- break
- print(f"[step3] 检测到白屏,关App休息后重启(第{white_restart_count}/{MAX_WHITE_RESTART}次)")
- _save_progress(ex.device_id, keyword, visited, total_scroll_px, batch_no)
- try:
- ex.driver.app_stop(APP_PACKAGE) # 先把App彻底关掉再休息
- except Exception as _e:
- print(f" 关App失败(不影响流程): {_e}")
- import random as _random
- _rest = _random.uniform(60, 120)
- print(f" 休息{_rest:.0f}秒...")
- if not _chunked_sleep_with_report(_rest, "白屏重启"):
- stopped = True
- break
- 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))
- _swipe_next_batch(ex, step)
- remain -= step
- human_sleep(2)
- unknown_skip_streak = 0
- continue
- if home_restart_requested:
- # 进店被踢回首页等:重启App恢复(不计数、不终止任务)
- print("[step3] 进店被踢回首页/页面异常,重启App恢复")
- _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))
- _swipe_next_batch(ex, step)
- remain -= step
- human_sleep(2)
- continue
- if stopped:
- break
- if feed_end:
- # 底部「搜索结果较少,为你推荐相关店铺」→ 本屏真卡已采完,搜索结果耗尽,正常结束
- print("[step3] 底部耗尽标志在场,本屏真卡已采完 → 搜索结果耗尽,正常结束")
- break
- # 正常:滑动到本批最后一张卡片的店铺行落到屏幕5%处 → 该卡标题已离屏(下批全是新卡),
- # 下一张未访问卡完整落在 y≈9%h 起,带安全边距
- print(f"[step3] 已访问 {len(visited)} 个,滑动查看下一批")
- if len(named) >= 1:
- target_y = named[-1][4] # 本批最后一张卡片的配送距离y坐标
- swipe_dist = max(target_y - int(h * 0.05), int(h * 0.3))
- else:
- swipe_dist = int(h * 0.3)
- _swipe_next_batch(ex, swipe_dist)
- total_scroll_px += swipe_dist
- human_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 and not WHITE_SCREEN_REASSIGN:
- _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
- # ── 主入口 ──────────────────────────────────────────────
- 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)
|