| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022 |
- from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
- import requests
- import base64
- import cv2
- import uiautomator2 as u2
- import time
- import subprocess
- import re
- import random
- import datetime
- import sys
- import json
- import unicodedata
- from aip import AipOcr
- import threading
- from collections import deque
- import numpy as np
- import secrets
- import os
- import oss2
- import urllib.parse
- from config import Config
- from logger import setup_logger
- import logging
- from PIL import Image
- import http.client
- import traceback
- from pathlib import Path
- from db import get_mysql
- from scheduler import CrawlerScheduler
- from decimal import Decimal
- from area import AreaService
- try:
- from yzm import yzm as solve_captcha
- except Exception:
- solve_captcha = None
- setup_logger("mt_spider") # 初始化日志
- def get_access_token():
- AppKey = "tRK2RhyItCSh6BzyT4CNVXQa"
- AppSrcret = "TDgKiPo94i2mOM1sDqOuDnlcK1bG66jh"
- token_url = 'https://aip.baidubce.com/oauth/2.0/token'
- url = f"{token_url}?grant_type=client_credentials&client_id={AppKey}&client_secret={AppSrcret}"
- payload = ""
- headers = {
- 'Content-Type': 'application/json',
- 'Accept': 'application/json'
- }
- response = requests.request("POST", url, headers=headers, data=payload)
- try:
- return response.json()['access_token']
- except:
- return None
- LOOP_INTERVAL_SECONDS = 600 # 每轮任务之间的等待间隔(秒),默认10分钟
- DEVICE_ID = "T4VK4LM7AAUOV8AY" # 指定要连接的设备
- PLATFORM_MT = 4
- # True: 跑 device_list 里手动配置的任务;False: 从外部调度器获取任务
- MANUAL_MODE = False
- SEARCH_TASK_MODE = "name_with_each_spec"
- MAX_RUN_DEVICE_RETRIES = 3 # run_device 外层重试上限,超过后回告并停止
- OPEN_PRODUCT_LIST_PAGE_RETRY = 3
- FAILURE_NOTICE_THRESHOLD = 3
- REQUEST_ERROR_STOP_THRESHOLD = 5
- PRODUCT_LINK_STOP_THRESHOLD = 5
- # 是否记录"因为何种问题重新开始"的日志
- ENABLE_RESTART_REASON_LOG = True
- # 重新开始原因日志文件
- RESTART_REASON_LOG_FILE = "./restart_reason_logs/a_mt_restart_reason.log"
- # 是否启用"验证码连续重启失败"告警(飞书)
- ENABLE_CAPTCHA_RESTART_ALERT = False
- # 店铺补齐调试日志开关
- ENABLE_SHOP_DEBUG = False
- # 验证码连续重启失败告警阈值
- CAPTCHA_RESTART_ALERT_THRESHOLD = 3
- # 验证码连续重启计数持久化文件
- CAPTCHA_RESTART_COUNTER_FILE = "./restart_reason_logs/captcha_restart_counter.json"
- # 1小时内验证码导致重启超过此值则直接停止任务并回告
- CAPTCHA_STOP_THRESHOLD = 5
- CAPTCHA_STOP_WINDOW_SECONDS = 3600
- failure_notice_counters = {}
- failure_notice_lock = threading.Lock()
- captcha_restart_lock = threading.Lock()
- captcha_restart_counts_cache = None
- oss_bucket_cache_lock = threading.Lock()
- oss_bucket_cache = {}
- def decode_qr(image_path):
- for i in range(3):
- img = cv2.imread(image_path)
- # 放大2~4倍(关键)
- if (i == 0):
- img2 = img[1200:, :300]
- img3 = img[1500:2000,50:500]
- img = cv2.resize(img, None, fx=3, fy=3, interpolation=cv2.INTER_CUBIC)
- img2 = cv2.resize(img2, None, fx=3, fy=3, interpolation=cv2.INTER_CUBIC)
- else:
- img3 = img[1500:2000,50:500]
- img2 = img[1000:, :200]
- img = cv2.resize(img, None, fx=(3 - i), fy=(3 - i), interpolation=cv2.INTER_CUBIC)
- img2 = cv2.resize(img2, None, fx=(3 - i), fy=(3 - i), interpolation=cv2.INTER_CUBIC)
- detector = cv2.wechat_qrcode_WeChatQRCode()
- data, points = detector.detectAndDecode(img2)
- if data != ():
- return data
- data, points = detector.detectAndDecode(img3)
- if data != ():
- return data
- data, points = detector.detectAndDecode(img)
- if data != ():
- return data
- if (data == ()):
- data = ''
- return data
- class CollectionStopError(RuntimeError):
- """需要立即停止当前采集流程的致命异常。"""
- class AccountBlockedError(CollectionStopError):
- """疑似账号被封禁,需立即停止当前采集流程。"""
- class ProductLinkUnavailableError(CollectionStopError):
- """连续获取不到商品链接,需立即停止当前采集流程。"""
- class WindControlStuckError(CollectionStopError):
- """检测到风控卡死:连续重复商品 + "加载更多"按钮持续存在,疑似被风控限制。"""
- def is_high_resolution_device(d):
- """检测是否为高分辨率设备 (1220x2712)。
- 通过屏幕高度判断:>2000 为高分辨率,否则为低分辨率 (720x1640)。
- """
- try:
- h = d.info.get('displayHeight', 0)
- return h > 2000
- except Exception:
- return False
- def parse_optional_int(value, default=None):
- if value in (None, ""):
- return default
- try:
- return int(value)
- except (TypeError, ValueError):
- return default
- def parse_spec_list(value):
- if value is None:
- return []
- if isinstance(value, (list, tuple)):
- return [str(item).strip() for item in value if str(item).strip()]
- text = str(value).strip()
- if not text:
- return []
- parts = re.split(r"[,,/\s]+", text)
- return [part.strip() for part in parts if part.strip()]
- def normalize_match_text(value):
- text = "" if value is None else str(value)
- # 统一全角/半角,移除各种空白和零宽字符,避免"看起来一样但匹配失败"
- text = unicodedata.normalize("NFKC", text)
- text = re.sub(r"[\s\u00A0\u200B-\u200D\uFEFF]+", "", text)
- return text
- def build_search_variants(search_key, spec_list, mode=SEARCH_TASK_MODE):
- base_search_key = str(search_key or "").strip()
- cleaned_specs = [str(spec).strip() for spec in (spec_list or []) if str(spec).strip()]
- if mode == "name_with_each_spec" and cleaned_specs:
- variants = []
- seen = set()
- for spec in cleaned_specs:
- query = f"{base_search_key}{spec}".strip() if base_search_key else spec
- if query and query not in seen:
- variants.append({
- "search_key": query,
- "spec_list": [spec],
- })
- seen.add(query)
- if variants:
- return variants
- return [{
- "search_key": base_search_key,
- "spec_list": cleaned_specs,
- }]
- def _build_failure_counter_key(source, device_id, task_id=None, search_key=None):
- return f"{source}:{device_id}:{task_id or ''}:{search_key or ''}"
- def _is_transient_open_page_error(err_msg):
- text = str(err_msg or "")
- keywords = (
- "open_product_list_page",
- "点击首页搜索入口失败",
- "进入看病买药页失败",
- "vf_search_carousel_text",
- "看病买药",
- )
- return any(k in text for k in keywords)
- def should_send_failure_notice(counter_key, err_msg, threshold=FAILURE_NOTICE_THRESHOLD):
- is_transient = _is_transient_open_page_error(err_msg)
- with failure_notice_lock:
- if is_transient:
- count = failure_notice_counters.get(counter_key, 0) + 1
- failure_notice_counters[counter_key] = count
- return count >= threshold, count, True
- failure_notice_counters[counter_key] = 0
- return True, 1, False
- def reset_failure_notice_counter(counter_key):
- with failure_notice_lock:
- failure_notice_counters[counter_key] = 0
- def _is_captcha_related_error(err_msg, traceback_text=None):
- text = f"{err_msg or ''}\n{traceback_text or ''}"
- lower_text = text.lower()
- keywords_cn = ("验证码", "滑块", "拼图", "安全验证", "人机验证", "请点击")
- keywords_en = ("captcha", "slider", "puzzle", "verify", "verification", "yoda")
- return any(k in text for k in keywords_cn) or any(k in lower_text for k in keywords_en)
- def _load_captcha_restart_counts():
- global captcha_restart_counts_cache
- if captcha_restart_counts_cache is not None:
- return captcha_restart_counts_cache
- data = {}
- try:
- if os.path.exists(CAPTCHA_RESTART_COUNTER_FILE):
- with open(CAPTCHA_RESTART_COUNTER_FILE, "r", encoding="utf-8") as f:
- raw = json.load(f)
- if isinstance(raw, dict):
- for k, v in raw.items():
- try:
- key = str(k)
- if isinstance(v, list):
- # 新格式:时间戳列表,只保留字符串
- data[key] = [str(ts) for ts in v if isinstance(ts, str)]
- elif isinstance(v, (int, float)):
- # 兼容旧格式:单个整数 → 转为空列表(旧数据清零)
- data[key] = []
- else:
- data[key] = []
- except Exception:
- continue
- except Exception as e:
- logging.exception(f"读取验证码重启计数失败: {e}")
- captcha_restart_counts_cache = data
- return captcha_restart_counts_cache
- def _save_captcha_restart_counts(data):
- try:
- log_dir = os.path.dirname(CAPTCHA_RESTART_COUNTER_FILE)
- if log_dir:
- os.makedirs(log_dir, exist_ok=True)
- with open(CAPTCHA_RESTART_COUNTER_FILE, "w", encoding="utf-8") as f:
- json.dump(data, f, ensure_ascii=False, indent=2)
- except Exception as e:
- logging.exception(f"写入验证码重启计数失败: {e}")
- def _prune_expired_timestamps(timestamps, window_seconds):
- """剔除超过时间窗口的旧时间戳"""
- now = time.time()
- cutoff = now - window_seconds
- return [ts for ts in timestamps if _timestamp_to_epoch(ts) > cutoff]
- def _timestamp_to_epoch(ts):
- """将 ISO 时间字符串转为 epoch 秒,解析失败返回 0"""
- try:
- return time.mktime(time.strptime(str(ts), "%Y-%m-%dT%H:%M:%S"))
- except Exception:
- return 0
- def increase_captcha_restart_count(counter_key):
- """记录一次验证码重启,返回当前时间窗口内的累计次数"""
- with captcha_restart_lock:
- data = _load_captcha_restart_counts()
- timestamps = data.get(counter_key, [])
- if not isinstance(timestamps, list):
- timestamps = []
- # 追加当前时间
- now_str = time.strftime("%Y-%m-%dT%H:%M:%S")
- timestamps.append(now_str)
- # 剔除超过 1 小时的旧记录
- timestamps = _prune_expired_timestamps(timestamps, CAPTCHA_STOP_WINDOW_SECONDS)
- data[counter_key] = timestamps
- _save_captcha_restart_counts(data)
- return len(timestamps)
- def get_captcha_restart_count_in_window(counter_key, window_seconds=None):
- """查询时间窗口内的验证码重启次数(不追加新记录)"""
- if window_seconds is None:
- window_seconds = CAPTCHA_STOP_WINDOW_SECONDS
- with captcha_restart_lock:
- data = _load_captcha_restart_counts()
- timestamps = data.get(counter_key, [])
- if not isinstance(timestamps, list):
- return 0
- timestamps = _prune_expired_timestamps(timestamps, window_seconds)
- return len(timestamps)
- def reset_captcha_restart_count(counter_key):
- with captcha_restart_lock:
- data = _load_captcha_restart_counts()
- timestamps = data.get(counter_key)
- if timestamps:
- data[counter_key] = []
- _save_captcha_restart_counts(data)
- def should_send_captcha_restart_alert(restart_count):
- if not ENABLE_CAPTCHA_RESTART_ALERT:
- return False
- if CAPTCHA_RESTART_ALERT_THRESHOLD <= 0:
- return False
- if restart_count < CAPTCHA_RESTART_ALERT_THRESHOLD:
- return False
- return restart_count % CAPTCHA_RESTART_ALERT_THRESHOLD == 0
- def record_restart_reason(
- reason,
- device_id=None,
- task_id=None,
- step=None,
- action=None,
- fail_count=None,
- retry_limit=None,
- cycle_no=None,
- search_key=None,
- source=None,
- exc=None,
- traceback_text=None,
- ):
- if not ENABLE_RESTART_REASON_LOG:
- return
- payload = {
- "time": time.strftime("%Y-%m-%d %H:%M:%S"),
- "source": source,
- "reason": str(reason),
- "device_id": device_id,
- "task_id": task_id,
- "step": step,
- "action": action,
- "fail_count": fail_count,
- "retry_limit": retry_limit,
- "cycle_no": cycle_no,
- "search_key": search_key,
- "error": str(exc) if exc is not None else None,
- "traceback": traceback_text,
- }
- try:
- log_dir = os.path.dirname(RESTART_REASON_LOG_FILE)
- if log_dir:
- os.makedirs(log_dir, exist_ok=True)
- with open(RESTART_REASON_LOG_FILE, "a", encoding="utf-8") as f:
- f.write(json.dumps(payload, ensure_ascii=False) + "\n")
- except Exception as log_err:
- logging.exception(f"写入重启原因日志失败: {log_err}")
- class SpiderMonitor(threading.Thread):
- """全局弹窗监控线程(增强版)"""
- def __init__(self, spider_instance):
- super().__init__(daemon=True)
- self.spider = spider_instance
- self.running = True
- self.pausing = threading.Event() # 主线程同步事件
- self.last_verification_time = 0
- self.verification_count = 0
- # 验证码重试次数
- self.MAX_VERIFICATION_RETRY = 6
- self.recent_clicks = deque(maxlen=10) # 防重复点击
- self.logger = logging.getLogger("SpiderMonitor")
- self.last_verification_probe_log_ts = 0
- self.last_reconnect_ts = 0
- # 验证码出现频率统计(1小时滑动窗口)
- self.captcha_appearance_timestamps = [] # 验证码出现时间戳列表
- self.MAX_CAPTCHA_APPEARANCES = 5 # 1小时内最多出现次数
- self.CAPTCHA_APPEARANCE_WINDOW = 3600 # 时间窗口(秒)
- self.captcha_appearance_limit_reached = False # 是否已达到验证码出现上限
- # 可配置化弹窗规则
- self.popup_rules = {
- "simple": [
- ('//*[@text="确定"]', "点击确定"),
- ('//*[@text="允许"]', "点击允许"),
- ('//*[@text="关闭"]', "点击关闭"),
- ('//*[@resource-id="com.sankuai.meituan:id/close"]', "关闭按钮"),
- ('//*[@resource-id="com.sankuai.meituan:id/address_center_location_close"]', "关闭按钮"),
- ('//*[@resource-id="com.sankuai.meituan:id/location_close"]', "关闭按钮"),
- ('//*[@resource-id="com.sankuai.meituan:id/btn_close"]', "关闭按钮"),
- ],
- "verification": [
- '//*[contains(@text, "依次点击")]',
- '//*[contains(@text, "拖动滑块刚")]', # 这个需要拖动滑块至最右边,然后再截图
- '//*[contains(@text, "请输入图片中的内容")]',
- '//*[contains(@text, "用最短线连接")]',
- '//*[contains(@text, "请按语序依次点击")]',
- '//*[contains(@text, "请向右滑动滑块")]',
- '//*[contains(@text, "请拖动下方滑块完成拼图")]',
- '//*[contains(@text, "请点击") and contains(@text, "图")]',
- '//*[contains(@text, "验证码")]',
- '//*[contains(@text, "安全验证")]',
- '//*[contains(@text, "完成拼图")]',
- '//*[contains(@text, "滑块")]',
- '//*[contains(@resource-id, "captcha")]',
- '//*[contains(@resource-id, "yoda")]',
- '//*[contains(@resource-id, "puzzle")]',
- '//*[contains(@resource-id, "slider")]',
- '//*[contains(@resource-id, "verify")]',
- ]
- }
- def _device_tag(self):
- device_id = getattr(self.spider, "device_id", None) or "unknown"
- device_name = getattr(self.spider, "device_name", None) or "unknown"
- equipment_id = getattr(self.spider, "equipment_id", None)
- if equipment_id not in (None, ""):
- return f"[device_id={device_id} device_name={device_name} equipment_id={equipment_id}]"
- return f"[device_id={device_id} device_name={device_name}]"
- def run(self):
- while self.running:
- try:
- handled = self.check_and_handle_popup()
- time.sleep(2 if handled else 1)
- except http.client.RemoteDisconnected as e:
- self.logger.exception("%s 监控线程连接断开: %s", self._device_tag(), e)
- now = time.time()
- # 监控线程受控重连:最小间隔内仅触发一次,避免抖动重连
- if now - self.last_reconnect_ts >= 8:
- self.last_reconnect_ts = now
- try:
- if hasattr(self.spider, "reconnect_device"):
- if self.spider.reconnect_device():
- self.logger.info("%s 监控线程触发重连成功", self._device_tag())
- except Exception:
- self.logger.exception("%s 监控线程触发重连失败", self._device_tag())
- time.sleep(2)
- except Exception as e:
- self.logger.exception("%s 监控线程异常: %s", self._device_tag(), e)
- time.sleep(1)
- def _is_recent_click(self, xpath):
- """防止重复点击同一个弹窗"""
- key = f"{xpath}_{int(time.time())}"
- if key in self.recent_clicks:
- return True
- self.recent_clicks.append(key)
- return False
- def _try_auto_solve_verification(self, d):
- if solve_captcha is None:
- return False
- try:
- device_id = getattr(self.spider, "device_id", None)
- try:
- solve_captcha(d, device_id=device_id)
- except TypeError:
- solve_captcha(d)
- return True
- except Exception as e:
- self.logger.exception("%s auto captcha solve failed: %s", self._device_tag(), e)
- return False
- def _get_xpath_bounds(self, d, xpath):
- try:
- node = d.xpath(xpath)
- if not node.exists:
- return None
- info = node.info or {}
- bounds = info.get("visibleBounds") or info.get("bounds") or {}
- if not bounds:
- return None
- left = int(bounds.get("left", 0))
- right = int(bounds.get("right", 0))
- top = int(bounds.get("top", 0))
- bottom = int(bounds.get("bottom", 0))
- width = right - left
- height = bottom - top
- if width <= 0 or height <= 0:
- return None
- return {
- "left": left,
- "right": right,
- "top": top,
- "bottom": bottom,
- "width": width,
- "height": height,
- }
- except Exception:
- return None
- def _xpath_exists_meaningfully(self, d, xpath, min_width=24, min_height=12):
- bounds = self._get_xpath_bounds(d, xpath)
- if not bounds:
- return False
- return bounds["width"] >= min_width and bounds["height"] >= min_height
- def _get_active_verification_xpaths(self, d):
- active = []
- active_containers = []
- container_xpaths = [
- '//*[contains(@resource-id, "captcha")]',
- '//*[contains(@resource-id, "yoda")]',
- '//*[@resource-id="puzzleSliderBox"]',
- '//*[@resource-id="puzzleImageMain"]',
- '//*[@resource-id="yodaBoxWrapper"]',
- '//*[@resource-id="yodaBox"]',
- '//*[contains(@resource-id, "verify")]',
- '//*[contains(@resource-id, "slider")]',
- '//*[contains(@resource-id, "puzzle")]',
- ]
- for xpath in container_xpaths:
- try:
- if self._xpath_exists_meaningfully(d, xpath, min_width=80, min_height=40):
- active_containers.append(xpath)
- except Exception:
- continue
- for xpath in self.popup_rules["verification"]:
- try:
- if self._xpath_exists_meaningfully(d, xpath):
- active.append(xpath)
- except Exception:
- continue
- if active:
- return list(dict.fromkeys(active))
- if active_containers:
- now = time.time()
- if now - self.last_verification_probe_log_ts >= 8:
- self.logger.info(
- "%s captcha container hit without explicit text rules: %s",
- self._device_tag(),
- active_containers[:3]
- )
- self.last_verification_probe_log_ts = now
- return list(dict.fromkeys(active_containers))
- fallback_xpaths = [
- '//*[contains(@text, "验证")]',
- '//*[contains(@text, "滑块")]',
- '//*[contains(@text, "拼图")]',
- '//*[contains(@text, "请点击")]',
- ]
- for xpath in fallback_xpaths:
- try:
- if self._xpath_exists_meaningfully(d, xpath):
- active.append(xpath)
- except Exception:
- continue
- return list(dict.fromkeys(active))
- def _confirm_active_verification_xpaths(self, d, rounds=3, interval=0.35, min_hits=2):
- hit_counter = {}
- for idx in range(rounds):
- for xpath in self._get_active_verification_xpaths(d):
- hit_counter[xpath] = hit_counter.get(xpath, 0) + 1
- if idx < rounds - 1:
- time.sleep(interval)
- confirmed = [xpath for xpath, hits in hit_counter.items() if hits >= min_hits]
- return list(dict.fromkeys(confirmed))
- def _wait_verification_cleared(
- self,
- d,
- timeout=120,
- stable_rounds=3,
- interval=1.2,
- solve_retry_interval=6
- ):
- """
- 必须连续 stable_rounds 次都检测不到验证码,才认为真正处理完成。
- """
- start = time.time()
- stable_count = 0
- last_active = []
- last_retry_solve_ts = 0
- while self.running and time.time() - start < timeout:
- active = self._get_active_verification_xpaths(d)
- if active:
- last_active = active
- stable_count = 0
- now = time.time()
- # 卡住时持续重试自动验证码处理,避免"只处理一次后一直挂起"
- if now - last_retry_solve_ts >= solve_retry_interval:
- if self._try_auto_solve_verification(d):
- self.logger.info("%s captcha auto-solver retried", self._device_tag())
- last_retry_solve_ts = now
- else:
- stable_count += 1
- if stable_count >= stable_rounds:
- return True, []
- time.sleep(interval)
- remaining = self._confirm_active_verification_xpaths(d, rounds=4, interval=0.4, min_hits=2)
- if not remaining:
- return True, []
- return False, remaining or last_active
- def _get_active_simple_popups(self, d):
- rules = self.popup_rules["simple"]
- if not rules:
- return []
- active = []
- for xpath, desc in rules:
- try:
- exists = bool(d.xpath(xpath).exists)
- except Exception:
- exists = False
- if exists:
- active.append((xpath, desc))
- return active
- def _handle_special_verify_pages(self, d):
- """定向处理验证码后的异常页,避免误点通用按钮。"""
- rules = [
- (
- '//*[@text="您的网络好像不太给力,请稍后再试"]',
- '//*[@text="重新加载"]',
- "网络不给力页-点击重新加载",
- ),
- (
- '//*[contains(@text, "verify.meituan.com/v2/app/general_page")]',
- '//*[@text="关闭页面"]',
- "general_page页-点击关闭页面",
- ),
- ]
- for detect_xpath, click_xpath, desc in rules:
- try:
- if d.xpath(detect_xpath).exists and d.xpath(click_xpath).exists:
- self.logger.info("%s 检测到%s", self._device_tag(), desc)
- d.xpath(click_xpath).click()
- return True
- except Exception:
- continue
- return False
- def check_and_handle_popup(self):
- d = self.spider.d
- # 0. 处理验证码后的异常页(定向按钮)
- if self._handle_special_verify_pages(d):
- return True
- # 1. 处理简单弹窗
- for xpath, desc in self._get_active_simple_popups(d):
- if self._is_recent_click(xpath):
- continue
- try:
- self.logger.info("%s 检测到弹窗: %s", self._device_tag(), desc)
- d.xpath(xpath).click()
- return True
- except Exception:
- continue
- # 2. 处理验证码弹窗
- active_verification = self._confirm_active_verification_xpaths(d, rounds=3, interval=0.35, min_hits=2)
- if active_verification:
- if not self.pausing.is_set():
- # 记录验证码出现时间戳,统计1小时窗口内出现次数
- now = time.time()
- self.captcha_appearance_timestamps.append(now)
- cutoff = now - self.CAPTCHA_APPEARANCE_WINDOW
- self.captcha_appearance_timestamps = [
- ts for ts in self.captcha_appearance_timestamps if ts > cutoff
- ]
- captcha_appearance_count = len(self.captcha_appearance_timestamps)
- self.last_verification_time = now
- self.verification_count += 1
- self.logger.warning(
- "%s 验证码弹窗触发(%s),1小时内第%s次,等待处理完成后再继续...",
- self._device_tag(),
- active_verification[:3],
- captcha_appearance_count,
- )
- self.pausing.set() # 通知主线程暂停
- if self._try_auto_solve_verification(d):
- self.logger.info("%s captcha auto-solver triggered", self._device_tag())
- cleared, remaining = self._wait_verification_cleared(d)
- if cleared:
- self.logger.info("%s 验证码已处理,准备恢复任务", self._device_tag())
- self.pausing.clear() # 放行主线程
- else:
- # 超时未清除:计数累加,主线程继续阻塞,等待下一轮处理或达到上限
- self.verification_count += 1
- self.logger.warning(
- "%s 验证码处理超时(第%s次),继续阻塞主线程: %s",
- self._device_tag(),
- self.verification_count,
- remaining[:3]
- )
- # 1小时内验证码出现次数上限检查
- captcha_appearance_count = len(self.captcha_appearance_timestamps)
- if captcha_appearance_count >= self.MAX_CAPTCHA_APPEARANCES:
- self.logger.error(
- "%s 1小时内验证码出现%s次,达到上限%s,终止任务",
- self._device_tag(),
- captcha_appearance_count,
- self.MAX_CAPTCHA_APPEARANCES,
- )
- self.captcha_appearance_limit_reached = True
- self.pausing.clear()
- self.running = False
- return True
- # 重试上限检查(放在外面,超时累加计数后也能触发)
- if self.verification_count > self.MAX_VERIFICATION_RETRY:
- self.logger.error("%s 验证码重试超限(%s次),终止任务", self._device_tag(), self.verification_count)
- self.pausing.clear()
- self.running = False
- return True
- # 3. 处理广告弹窗(点击右上角)
- if d.xpath('//*[contains(@text, "广告")]').exists:
- w, h = d.info['displayWidth'], d.info['displayHeight']
- d.click(w - 50, 50)
- self.logger.info("%s 关闭广告弹窗", self._device_tag())
- return True
- return False
- def stop(self):
- self.running = False
- class MTScreenshot:
- def __init__(self, d, oss_config, search_key, title_key, scroll_times=2, compress_quality=7, resize_ratio=0.8,device_id=None,
- monitor=None):
- self.device_id = device_id
- # 接收外部已连接好的u2设备实例
- self.d = d
- self.is_high_res = is_high_resolution_device(d)
- self.search_key = search_key # 添加这行
- self.title_key = title_key
- # 优先复用外部监控,避免频繁创建监控线程
- self.monitor = monitor
- self.loggerMT = logging.getLogger()
- # 日志初始化
- self.logger = self._init_logger()
- # OSS配置与初始化(核心配置,无冗余)
- self.oss_config = oss_config
- self.oss_bucket = self._init_oss_bucket()
- # 截图核心参数
- self.scroll_times = scroll_times
- self.compress_quality = compress_quality
- self.resize_ratio = resize_ratio
- def _init_logger(self):
- # 极简日志配置,仅保留必要输出
- logger = logging.getLogger("mt_screenshot")
- logger.setLevel(logging.INFO)
- logger.handlers.clear()
- handler = logging.StreamHandler()
- handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
- logger.addHandler(handler)
- return logger
- def _init_oss_bucket(self):
- # 仅做OSS配置校验和Bucket连接,无额外功能
- access_key_id = self.oss_config.get("access_key_id")
- access_key_secret = self.oss_config.get("access_key_secret")
- endpoint = self.oss_config.get("endpoint")
- bucket_name = self.oss_config.get("bucket_name")
- if not all([access_key_id, access_key_secret, endpoint, bucket_name]):
- self.logger.warning("OSS配置不完整,无法上传")
- return None
- # 进程内复用同一套配置的Bucket,避免重复连接
- cache_key = (access_key_id, access_key_secret, endpoint, bucket_name)
- with oss_bucket_cache_lock:
- cached_bucket = oss_bucket_cache.get(cache_key)
- if cached_bucket is not None:
- self.logger.info("复用已缓存的OSS Bucket连接")
- return cached_bucket
- try:
- auth = oss2.Auth(access_key_id, access_key_secret)
- bucket = oss2.Bucket(auth, endpoint, bucket_name)
- bucket.get_bucket_info() # 验证连接
- with oss_bucket_cache_lock:
- oss_bucket_cache[cache_key] = bucket
- self.logger.info("OSS Bucket连接成功")
- return bucket
- except Exception as e:
- self.logger.error(f"OSS Bucket连接失败: {e}")
- return None
- def _upload_to_oss(self, local_path):
- # 极简上传逻辑,仅返回OSS URL或None
- if not self.oss_bucket or not os.path.exists(local_path):
- return None
- file_name = os.path.basename(local_path)
- safe_name = re.sub(r'[^\w\.\-]', '_', file_name)
- oss_key = f"{self.oss_config.get('oss_prefix', 'scrape_data/')}{safe_name}"
- try:
- oss2.resumable_upload(self.oss_bucket, oss_key, local_path)
- # 生成并返回完整OSS URL
- oss_file_url = f"https://{self.oss_config['bucket_name']}.{self.oss_config['endpoint']}/{urllib.parse.quote(oss_key, safe='/')}"
- return oss_file_url
- except Exception as e:
- self.logger.error(f"OSS上传失败: {e}")
- return None
- def safe_exec(self, func, *args, **kwargs):
- """
- 万能安全壳:执行 func 前检查验证码,
- 若监控线程已置位 pausing,则一直阻塞直到放行。
- """
- while self.monitor is not None and self.monitor.pausing.is_set():
- time.sleep(1)
- # 执行真正逻辑
- return func(*args, **kwargs)
- def _get_title(self):
- # try:
- def _inner():
- print(f'获取商品title时的搜索关键字:{self.title_key}')
- # 初始化
- drugs_name = ''
- specifications = ''
- title = ''
- # 循环的获取title为了有时间来处理人机验证
- for m in range(1, 6000):
- if self.d.xpath(f'//*[contains(@text, "{self.title_key}")]').exists:
- title = self.safe_exec(
- lambda: self.d.xpath(f'//*[contains(@text, "{self.title_key}")]').text
- )
- self.loggerMT.info(f"第{m}次获取title成功")
- print(f"第{m}次获取title成功")
- break
- else:
- time.sleep(1)
- # return drugs_name, specifications
- title = title[1:] if title.startswith('0') else title
- print(f'获取到药品标题:{title}')
- match = re.match(r'(\[[^\]]+\])(.*?)\s*((?:\d+\S*|\(.+))$', title)
- if match:
- drugs_name = title
- specifications = match.group(3).strip()
- print("药品名:", drugs_name)
- print("规格:", specifications)
- # print('完整药名:', drugs_name + specifications)
- return drugs_name # , specifications
- else:
- drugs_name = title
- specifications = ''
- return drugs_name
- # 用 safe_exec 包装内部逻辑,确保验证码阻塞
- return self.safe_exec(_inner)
- def _merge_screenshots(self, screens):
- # 仅拼接截图,无额外功能
- if len(screens) == 1:
- return screens[0].convert('RGB')
- rgb_screens = [s.convert('RGB') for s in screens]
- total_width = rgb_screens[0].width
- total_height = sum(s.height for s in rgb_screens)
- merged_img = Image.new('RGB', (total_width, total_height))
- y_offset = 0
- for img in rgb_screens:
- merged_img.paste(img, (0, y_offset))
- y_offset += img.height
- return merged_img
- def get_oss_url(self, title=None):
- """核心方法:截图+临时本地保存+上传OSS+上传成功删本地文件+返回OSS URL,可直接赋值给oss_file"""
- local_file_path = None
- try:
- # 1. 优先使用外部已采集标题,避免重复读取页面标题
- title = str(title or "").strip()
- if not title:
- title = self._get_title()
- self.logger.info(f"获取标题: {title[:20]}..." if title else "未获取到标题")
- else:
- self.logger.info(f"使用外部标题: {title[:20]}...")
- # 2. 生成本地文件路径
- timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + '_mt_' + self.device_id
- safe_title = re.sub(r'[\\/*?:"<>|]', '_', title)
- local_dir = "../scrape_data"
- os.makedirs(local_dir, exist_ok=True)
- local_file_path = os.path.join(local_dir, f"{timestamp}_{safe_title}.jpg")
- # 3. 滚动截图
- screen_list = [self.d.screenshot()]
- w, h = self.d.window_size()
- for i in range(self.scroll_times):
- # 可能滑动距离太短,截不到店名。原本是0.8
- # self.d.swipe(w // 2, h * 0.9, w // 2, h * 0.1, duration=random.uniform(0.6, 1.2))
- if self.is_high_res:
- self.d.swipe(w // 2, h * 0.75, w // 2, h * 0.25, duration=random.uniform(0.8, 1.5))
- else:
- self.d.swipe(w // 2, h * 0.85, w // 2, h * 0.15, duration=random.uniform(0.8, 1.5))
- time.sleep(random.uniform(2.0, 4.0))
- screen_list.append(self.d.screenshot())
- if self.d(textContains='商家服务').exists:
- # 看情况是否需要补滑
- break
- # 4. 拼接+压缩+保存
- merged_img = self._merge_screenshots(screen_list)
- if 0.1 < self.resize_ratio < 1.0:
- new_size = (int(merged_img.width * self.resize_ratio), int(merged_img.height * self.resize_ratio))
- resample_mode = Image.Resampling.LANCZOS if hasattr(Image, 'Resampling') else Image.LANCZOS
- merged_img = merged_img.resize(new_size, resample_mode)
- # 临时保存到本地
- merged_img.save(local_file_path, format='JPEG', quality=self.compress_quality)
- merged_img.close() # 释放长图句柄
- # 5. 上传OSS
- oss_url = self._upload_to_oss(local_file_path)
- # 6. 核心:OSS上传成功后,删除本地临时文件
- if oss_url is not None:
- try:
- self.logger.info(f"✅ OSS上传成功,已删除本地临时文件: {local_file_path}")
- except Exception as e:
- self.logger.warning(f"⚠️ OSS上传成功,但删除本地文件失败: {e}")
- return oss_url
- except Exception as e:
- self.logger.error(f"截图/上传失败: {e}")
- return None
- class MT:
- # 封号检测:类变量,跨实例共享,不因重建实例而重置
- open_page_fail_count = 0
- open_page_fail_threshold = 6
- def __init__(
- self,
- key,
- title_key,
- spec_list,
- brand,
- sort=None,
- collect_range=None,
- page_range=None,
- workflow_retry_limit=None,
- workflow_error_action=None,
- platform=None,
- task_id=None,
- enterprise_id=None,
- sampling_cycle=None,
- sampling_start_time=None,
- sampling_end_time=None,
- count=None,
- collect_equipment_id=None,
- device_name=None,
- collect_equipment_account_id=None,
- collect_region_id=None,
- collect_round=None,
- scheduler=None,
- ):
- self.scheduler = scheduler
- self.package_name = Config.PACKAGE_NAME
- self.access_token = get_access_token()
- self.APP_ID = '116857964'
- self.API_KEY = '1gAzACJOAr7BeILKqkqPOETh'
- self.SECRET_KEY = 'ZNArANb9GwJYgLKg4EfYhukKBfPdl1n3'
- self.client = AipOcr(self.APP_ID, self.API_KEY, self.SECRET_KEY)
- self.area_service = AreaService("city.json", "addr_prefix.json")
- self.table_name = "retrieve_scrape_data"
- self.shop_table_name = "retrieve_scrape_shop_info"
- self.loggerMT = logging.getLogger()
- self.logger = self.loggerMT
- self.task_id = task_id
- self.enterprise_id = enterprise_id
- self.platform = platform
- self.collect_equipment_id = collect_equipment_id
- self.device_name = device_name
- self.page = 0
- self.search_key = str(key or "").strip()
- self.title_key = str(title_key or "").strip()
- self.spec_list = [str(spec).strip() for spec in (spec_list or []) if str(spec).strip()]
- self.brand = str(brand or "").strip()
- self.sort = sort
- self.collect_equipment_account_id = collect_equipment_account_id
- self.collect_region_id = collect_region_id
- self.collect_round = collect_round
- self.sampling_cycle = sampling_cycle
- self.sampling_start_time = sampling_start_time
- self.sampling_end_time = sampling_end_time
- # self.count = parse_optional_int(count, None)
- self.count = 200
- if self.count is not None and self.count <= 0:
- self.count = None
- self.loggerMT.info(
- "[MT初始化] task_id=%s device_id=%s collect_equipment_id=%s platform=%s enterprise_id=%s "
- "sampling_cycle=%s sampling_start_time=%s sampling_end_time=%s target_count=%s",
- self.task_id,
- getattr(self, "device_id", None),
- self.collect_equipment_id,
- self.platform,
- self.enterprise_id,
- self.sampling_cycle,
- self.sampling_start_time,
- self.sampling_end_time,
- self.count,
- )
- self.collect_range = self.normalize_collect_range(collect_range)
- self.page_range = self.normalize_page_range(page_range)
- self.sort_key = 0
- self.unrelated_data = 0
- self.shop_data_num = 0
- self.collected_data_count = 0
- self.collected_count_lock = threading.Lock()
- self.target_count_reached_event = threading.Event()
- self.max_unrelated_data = 15
- self.request_error_count = 0
- self.request_error_threshold = REQUEST_ERROR_STOP_THRESHOLD
- self.product_link_missing_count = 0
- self.product_link_missing_threshold = PRODUCT_LINK_STOP_THRESHOLD
- # 封号检测:open_product_list_page 连续失败计数(类变量,跨实例共享,不因重建实例而重置)
- # 风控卡死检测:"加载更多"按钮检测
- self.load_more_check_rounds = 4
- self.load_more_check_interval = 30
- self.app_closed = False
- self.collection_cursor = {"page_no": 1, "item_index": 0}
- self.workflow_retry_limit = workflow_retry_limit or {
- "start_app": 3,
- "open_product_list_page": 3,
- "collect_single_product": 3,
- }
- self.workflow_error_action = workflow_error_action or {
- "start_app": "start_app",
- "open_product_list_page": "start_app",
- "collect_single_product": "back_to_list_page",
- }
- self.is_high_res = False # 连接设备后更新
- self.finish_reported = False
- self.post_process_executor = ThreadPoolExecutor(max_workers=4)
- self.post_process_futures = []
- self.post_process_lock = threading.Lock()
- self.max_pending_post_tasks = 80
- def get_collected_data_count(self):
- with self.collected_count_lock:
- return self.collected_data_count
- def has_reached_target_count(self):
- if self.count is None:
- return False
- if self.target_count_reached_event.is_set():
- return True
- return self.get_collected_data_count() >= self.count
- def mark_collected_data_saved(self):
- with self.collected_count_lock:
- self.collected_data_count += 1
- current_count = self.collected_data_count
- if self.count is not None and current_count >= self.count:
- self.target_count_reached_event.set()
- return current_count
- def finish_task_normally(self, end_page, reason):
- if not self.finish_reported and self.task_id:
- if self.scheduler:
- self.scheduler.post_report({
- "task_id": self.task_id,
- "platform": str(self.platform),
- "username": self.scheduler.username,
- "current_page": end_page,
- "crawled_count": self.get_collected_data_count(),
- "is_finished": 1,
- })
- self.finish_reported = True
- self.wr_re("删", self.device_id)
- print(reason)
- self.close()
- return True
- def finish_task_abnormally(self, end_page, reason, finish_status=0):
- if not self.finish_reported and self.task_id:
- self.scheduler.post_report({
- "task_id": self.task_id,
- "platform": str(self.platform),
- "username": self.scheduler.username,
- "current_page": end_page,
- "crawled_count": self.get_collected_data_count(),
- "is_finished": 0,
- "need_reassign": 1,
- "exception_type": 5,
- "remark": reason,
- })
- self.finish_reported = True
- print(reason)
- self.close()
- return False
- def _progress_file_path(self, device_id=None):
- target_device = device_id or getattr(self, "device_id", None)
- if not target_device:
- return None
- return f'./ycwj/{target_device}_{self.title_key}.txt'
- @staticmethod
- def normalize_collect_range(collect_range):
- if not collect_range:
- return None
- start = None
- end = None
- if isinstance(collect_range, dict):
- start = collect_range.get("start")
- end = collect_range.get("end")
- elif isinstance(collect_range, (list, tuple)) and len(collect_range) >= 2:
- start, end = collect_range[0], collect_range[1]
- elif isinstance(collect_range, str):
- matched = re.match(r"^\s*(\d+(?:\.\d+)?)\s*[-,~]\s*(\d+(?:\.\d+)?)\s*$", collect_range)
- if matched:
- start, end = matched.group(1), matched.group(2)
- try:
- start = float(start)
- end = float(end)
- except (TypeError, ValueError):
- return None
- if start < 0 or end < 0:
- return None
- if start > end:
- start, end = end, start
- return {"start": start, "end": end}
- @staticmethod
- def normalize_page_range(page_range):
- if not page_range:
- return None
- start = None
- end = None
- if isinstance(page_range, dict):
- start = page_range.get("start")
- end = page_range.get("end")
- elif isinstance(page_range, (list, tuple)) and len(page_range) >= 2:
- start, end = page_range[0], page_range[1]
- elif isinstance(page_range, str):
- matched = re.match(r"^\s*[\[\(]?\s*(\d+)\s*[,,\-~]\s*(\d+)\s*[\]\)]?\s*$", page_range)
- if matched:
- start, end = matched.group(1), matched.group(2)
- try:
- start = int(float(start))
- end = int(float(end))
- except (TypeError, ValueError):
- return None
- if start <= 0 or end <= 0:
- return None
- if start > end:
- start, end = end, start
- return {"start": start, "end": end}
- def stop_app(self):
- if getattr(self, "d", None) is None or self.app_closed:
- return
- try:
- self.d.app_stop(self.package_name)
- self.app_closed = True
- except Exception as e:
- self.loggerMT.warning(f"关闭应用失败: {e}")
- time.sleep(1)
- def start_app(self):
- self.d.app_start(self.package_name)
- self.app_closed = False
- time.sleep(1)
- def restart_app(self):
- self.stop_app()
- self.start_app()
- def close(self):
- self.stop_app()
- def li_or_lo(self, key="升序"):
- """
- 排序操作:升序或降序
- :param key: "升序" 或 "降序"
- """
- if key == "升序":
- # 增加重试机制,最多尝试3次
- max_retries = 3
- for attempt in range(max_retries):
- try:
- # 1. 点击“综合”标签
- # 优先使用text定位,若失败可尝试其他属性
- comprehensive = self.d.xpath('//*[@text="综合"]')
- if comprehensive.exists:
- comprehensive.click(timeout=3) # 等待元素可点击
- time.sleep(0.5)
- # 2. 点击“总价低到高”选项
- low_to_high = self.d.xpath('//*[@text="总价低到高"]')
- if low_to_high.exists:
- low_to_high.click(timeout=3)
- time.sleep(0.7)
- self.sort_key += 1
- self.logger.info("排序已切换为升序(总价低到高)")
- return # 成功执行后退出
- except Exception as e:
- self.logger.warning(f"升序排序尝试 {attempt+1}/{max_retries} 失败: {e}")
- # 如果失败,等待并重试
- time.sleep(1)
- # 若最后一次重试仍失败,抛出异常
- if attempt == max_retries - 1:
- self.logger.error("升序排序最终失败,请检查UI元素")
- raise
- elif key == "降序":
- # 当前版本不支持降序,记录日志但不执行操作
- self.logger.warning('2026_05版本不存在降序,请勿调用')
- print('2026_05版本不存在降序,请勿调用')
- # 可根据需要选择抛出异常或直接返回
- # raise NotImplementedError("降序功能未实现")
- else:
- raise ValueError(f"无效的排序参数: {key},仅支持 '升序' 或 '降序'")
- def wr_re(self, mod, device_id, sort=None, page=None):
- file_path = self._progress_file_path(device_id)
- if not file_path:
- return None
- if mod == "写":
- try:
- data = {
- "page": page if page else "",
- "sort": sort if sort else "",
- }
- os.makedirs(os.path.dirname(file_path), exist_ok=True)
- with open(file_path, 'w', encoding='utf-8') as f:
- json.dump(data, f, ensure_ascii=False, indent=2)
- print(f"进度保存成功:{sort},{page}页")
- except Exception as e:
- print("保存进度失败")
- elif mod == "读":
- self.li_or_lo()
- try:
- if not os.path.exists(file_path):
- return None
- with open(file_path, 'r', encoding='utf-8') as f:
- data = json.load(f)
- i = 0
- while True:
- self.wait_for_ready(getattr(self, "monitor", None))
- if i == data['page']:
- self.page = data['page']
- print("当前页", self.page)
- break
- else:
- i += 1
- if self.is_high_res:
- self.d.drag(300, 2600, 300, 400, 1)
- else:
- self.d.drag(300, 1400, 300, 400, 1)
- return data
- except Exception as e:
- print(f"读取进度失败")
- return None
- elif mod == "删":
- try:
- if os.path.exists(file_path):
- os.remove(file_path)
- print(f"进度文件已删除:{file_path}")
- return True
- except Exception as e:
- print(f"删除进度文件失败: {e}")
- return False
- return None
- # 任何一个spec满足都算有效
- def is_link_spec_useful(self, product_title):
- normalized_title = normalize_match_text(product_title)
- if len(self.spec_list) == 0:
- return True
- for spec in self.spec_list:
- if normalize_match_text(spec) in normalized_title:
- return True
- return False
- # TODO 继续优化这里的判断逻辑,可以考虑搭配config的修改
- def is_link_useful(self, product_title):
- normalized_title = normalize_match_text(product_title)
- normalized_title_key = normalize_match_text(self.title_key)
- normalized_brand = normalize_match_text(self.brand)
- # 1. 药品名必须在标题里
- if normalized_title_key != "" and normalized_title_key not in normalized_title:
- print(f"当前商品名称:{product_title} 不包含{self.title_key}关键字")
- return False
- # 2. 把药品名从标题里去掉后再检查品牌
- # (避免品牌是通用名子串的干扰,如品牌"午时" ⊂ 药名"午时茶颗粒")
- check_target = normalized_title.replace(normalized_title_key, "") if normalized_title_key else normalized_title
- if normalized_brand != "" and normalized_brand not in check_target:
- print(f"当前商品名称:{product_title} 去掉药名后不包含{self.brand}品牌")
- return False
- if not self.is_link_spec_useful(product_title):
- print(f"当前商品名称:{product_title} 不包含{self.spec_list}品规")
- return False
- return True
- @staticmethod
- def get_sleep_time():
- # return random.randint(5, 8)
- # return 1
- return random.uniform(0.5, 1.0)
- @staticmethod
- def get_current_date():
- return datetime.datetime.now().strftime('%Y/%m/%d')
- def get_shop_name_from_current_page(self):
- """
- 仅从当前商品详情页读取店铺名,不做任何页面跳转。
- """
- shop_name = self.get_first_text_by_xpaths([
- '//android.widget.ScrollView/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[last()]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[3]/android.widget.FrameLayout[1]/android.widget.TextView',
- '//android.widget.ScrollView/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[last()-1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[3]/android.widget.FrameLayout[1]/android.widget.TextView',
- ])
- if shop_name:
- print(f'获取到店铺名:{shop_name}')
- return shop_name
- def get_shop_name(self):
- """
- 获取店铺名
- :return:
- """
- shop_name = self.get_shop_name_from_current_page()
- if shop_name:
- return shop_name
- try:
- # 点击店铺进入后获取店铺名称
- print("点击店铺进入后获取店铺名称")
- self.enter_shop()
- shop_xpath = '//*[@resource-id="com.sankuai.meituan:id/layout_header_view"]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]//android.widget.FrameLayout[2]/android.widget.FrameLayout[1]/android.widget.TextView'
- if self.d.xpath(shop_xpath).exists:
- shop_name = self.d.xpath(shop_xpath).text
- self.swipe_back(1)
- return shop_name
- shop_name = ''
- return shop_name
- except Exception as e:
- print(f'获取店铺名出错:{e}')
- return ''
- def get_qualification_number(self):
- """
- 获取资质编号
- :return:
- """
- try:
- # 方法1:精准 XPath
- elem = self.d.xpath(
- '//*[@resource-id="com.sankuai.meituan:id/mil_container"]/android.webkit.WebView[1]/android.webkit.WebView[1]/android.view.View[1]/android.view.View[1]/android.widget.TextView[2]')
- if elem.exists:
- text = elem.text
- if text:
- return text.replace('资质编号:', '').strip()
- # 方法2:模糊匹配任意包含"资质编号"的 TextView
- elem2 = self.d.xpath('//android.widget.TextView[contains(@text, "资质编号")]')
- if elem2.exists:
- text = elem2.text
- if text:
- return text.replace('资质编号:', '').strip()
- # 方法3:更通用的包含匹配(不限 TextView)
- elem3 = self.d.xpath('//*[contains(@text, "资质编号")]')
- if elem3.exists:
- text = elem3.text
- if text:
- return text.replace('资质编号:', '').strip()
- return None
- except Exception as e:
- print(f"获取资质编号失败: {e}")
- return None
- def get_shop_address(self):
- try:
- shop_address_xpaths = [
- '//*[@resource-id="com.sankuai.meituan:id/wm_sc_drug_shop_content_mrn_container_id_2"]/android.widget.FrameLayout/android.widget.FrameLayout/android.view.ViewGroup/android.view.ViewGroup/android.widget.ScrollView/android.view.ViewGroup/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.widget.TextView',
- '//*[@resource-id="com.sankuai.meituan:id/wm_sc_drug_shop_content_mrn_container_id_2"]/android.widget.FrameLayout/android.widget.FrameLayout/android.view.ViewGroup/android.view.ViewGroup/android.widget.ScrollView/android.view.ViewGroup/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.widget.TextView'
- ]
- shop_address = self.get_first_text_by_xpaths(shop_address_xpaths)
- print(f'111-获取到店铺地址:{shop_address}')
- if '发货时间' in shop_address:
- print(f'店铺地址包含发货时间,再次获取店铺地址')
- shop_address = self._read_xpath_text(shop_address_xpaths[1])
- if shop_address:
- print(f'222-获取到店铺地址:{shop_address}')
- else:
- print(f'222-xpath2获取店铺地址失败')
- print(f'333-获取到店铺地址:{shop_address}')
- if "近30天平均发货" not in shop_address :
- return shop_address
- else:
- return ""
- except:
- print(f'获取店铺地址出错-get_shop_address')
- return None
- def execute_db_write(self, sql, params, action_desc, max_retries=5):
- for attempt in range(max_retries):
- conn = None
- try:
- conn = get_mysql()
- with conn.cursor() as cur:
- cur.execute(sql, params)
- conn.commit()
- print(f"{action_desc}成功")
- return True
- except Exception as e:
- print(f'{action_desc}异常 (尝试 {attempt + 1}/{max_retries}): {e}')
- if conn:
- conn.rollback()
- if attempt == max_retries - 1:
- print(f"{action_desc}失败,达到最大重试次数")
- return False
- time.sleep(2)
- finally:
- if conn:
- conn.close()
- def query_exists(self, sql, params, error_desc):
- conn = None
- try:
- conn = get_mysql()
- with conn.cursor() as cur:
- cur.execute(sql, params)
- return bool(cur.fetchone())
- except Exception as e:
- print(f"{error_desc}错误: {str(e)}")
- return None
- finally:
- if conn:
- conn.close()
- def save_to_database(self, data):
- add_sql = f"""
- INSERT IGNORE INTO {self.table_name} (
- enterprise_id, platform_id, platform_item_id, province_id, city_id,
- province_name, city_name, area_info, product_name, product_specs,
- one_box_price, manufacture_date, expiry_date, manufacturer, approval_number,
- is_sold_out, online_posting_count, continuous_listing_count, link_url,
- store_name, store_url, shipment_province_id, shipment_province_name,
- shipment_city_id, shipment_city_name, company_name, qualification_number,
- scrape_date, min_price, number, sales, inventory, snapshot_url,
- product_brand, search_name, insert_time ,update_time, collect_config_info,
- collect_equipment_account_id ,collect_region_id ,collect_round,
- shop_id, company_id, task_id
- ) VALUES (
- %s, %s, %s, %s, %s,
- %s, %s, %s, %s, %s,
- %s, %s, %s, %s, %s,
- %s, %s, %s, %s,
- %s, %s, %s, %s,
- %s, %s, %s, %s,
- %s, %s, %s, %s, %s, %s,
- %s, %s, %s, %s, %s, %s, %s,
- %s,
- %s, %s,
- %s
- )
- """
- store_name = data.get('store_name', '')
- params = (
- data['enterprise_id'],
- data['platform_id'],
- data['platform_item_id'],
- data['province_id'],
- data['city_id'],
- data['province_name'],
- data['city_name'],
- data['area_info'],
- data['product_name'],
- data['product_specs'],
- data['one_box_price'],
- data['manufacture_date'],
- data['expiry_date'],
- data['manufacturer'],
- data['approval_number'],
- data['is_sold_out'],
- data['online_posting_count'],
- data['continuous_listing_count'],
- data['link_url'],
- store_name,
- data['store_url'],
- data['shipment_province_id'],
- data['shipment_province_name'],
- data['shipment_city_id'],
- data['shipment_city_name'],
- data['company_name'],
- data['qualification_number'],
- data['scrape_date'],
- data['min_price'],
- data['number'],
- data['sales'],
- data['inventory'],
- data['snapshot_url'],
- data['product_brand'],
- data['search_name'],
- data['insert_time'],
- data['update_time'],
- data['collect_config_info'],
- data['collect_equipment_account_id'],
- data['collect_region_id'],
- data['collect_round'],
- store_name, # shop_id = store_name
- store_name, # company_id = store_name
- data.get('task_id'),
- )
- return self.execute_db_write(add_sql, params, "保存商品数据到数据库")
- def save_shop_info_to_database(self, data):
- print(f'保存店铺数据到数据库:{data}')
- now_str = time.strftime('%Y-%m-%d %H:%M:%S')
- def _clean(v):
- v = str(v or '').strip()
- return '' if v in ('无', '無') else v
- shop = str(data.get('shop') or '').strip()
- contact_address = _clean(data.get('contact_address') or data.get('business_license_address'))
- business_license_address = _clean(data.get('business_license_address') or contact_address)
- qualification_number = _clean(data.get('qualification_number'))
- business_license_company = _clean(data.get('business_license_company'))
- scrape_date = str(data.get('scrape_date') or self.get_current_date()).strip()
- platform = str(data.get('platform') or self.platform or '4').strip()
- province = str(data.get('province') or '').strip()
- city = str(data.get('city') or '').strip()
- create_time = str(data.get('create_time') or now_str).strip()
- update_time = str(data.get('update_time') or now_str).strip()
- if self.shop_is_exists_database(shop, platform):
- if ENABLE_SHOP_DEBUG:
- print(f"[SHOP-DEBUG] save_shop_info_to_database: 命中UPDATE, shop={shop}")
- existing = self.get_shop_info_from_database(shop, platform) or {}
- existing_contact = str(existing.get("contact_address") or "").strip()
- existing_biz_address = str(existing.get("business_license_address") or "").strip()
- existing_company = str(existing.get("business_license_company") or "").strip()
- existing_qn = str(existing.get("qualification_number") or "").strip()
- existing_province = str(existing.get("province") or "").strip()
- existing_city = str(existing.get("city") or "").strip()
- contact_address = contact_address or existing_contact or existing_biz_address
- business_license_address = business_license_address or existing_biz_address or existing_contact
- business_license_company = business_license_company or existing_company
- qualification_number = qualification_number or existing_qn
- province = province or existing_province
- city = city or existing_city
- if (not province) and (not city):
- match = self.area_service.search_area(
- business_license_address or contact_address)
- if match:
- province = match.province or province
- city = match.city or city
- if ENABLE_SHOP_DEBUG:
- print(
- f"[SHOP-DEBUG] update payload: shop={shop}, "
- f"company={business_license_company}, qn={qualification_number}, "
- f"contact_address={contact_address}, biz_address={business_license_address}, "
- f"province={province}, city={city}, platform={platform}"
- )
- update_sql = f"""
- UPDATE {self.shop_table_name}
- SET contact_address = %s,
- qualification_number = %s,
- business_license_company = %s,
- business_license_address = %s,
- scrape_date = %s,
- platform = %s,
- province = %s,
- city = %s,
- update_time = %s,
- shop_id = %s,
- company_id = %s
- WHERE shop = %s
- """
- update_params = (
- contact_address,
- qualification_number,
- business_license_company,
- business_license_address,
- scrape_date,
- platform,
- province,
- city,
- update_time,
- shop, # shop_id = shop
- shop, # company_id = shop
- shop
- )
- return self.execute_db_write(update_sql, update_params, "更新店铺数据到数据库")
- if ENABLE_SHOP_DEBUG:
- print(f"[SHOP-DEBUG] save_shop_info_to_database: 命中INSERT, shop={shop}")
- if ENABLE_SHOP_DEBUG:
- print(
- f"[SHOP-DEBUG] insert payload: shop={shop}, "
- f"company={business_license_company}, qn={qualification_number}, "
- f"contact_address={contact_address}, biz_address={business_license_address}, "
- f"province={province}, city={city}, platform={platform}"
- )
- add_sql = f"""
- INSERT INTO {self.shop_table_name}
- (shop, contact_address, qualification_number, business_license_company, business_license_address, scrape_date, platform, province, city, create_time, update_time, shop_id, company_id)
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
- """
- add_params = (
- shop,
- contact_address,
- qualification_number,
- business_license_company,
- business_license_address,
- scrape_date,
- platform,
- province,
- city,
- create_time,
- update_time,
- shop, # shop_id = shop
- shop, # company_id = shop
- )
- return self.execute_db_write(add_sql, add_params, "保存店铺数据到数据库")
- def swipe_back(self, no):
- """
- 返回
- :param no: 回退次数
- :return:
- """
- for idx in range(no):
- self.d.press('back')
- time.sleep(0.5)
- def drug_price(self):
- """
- 获取药品价格
- :return:
- """
- time.sleep(0.5)
- try:
- price_str = ""
- price_int_xpath = '//*[@text="¥"]/../../android.widget.FrameLayout[2]/android.widget.TextView[1]'
- price_decimal_xpath = '//*[@text="¥"]/../../android.widget.FrameLayout[3]/android.widget.TextView[1]'
- price_int_node = self.d.xpath(price_int_xpath)
- if price_int_node.exists and price_int_node.text:
- price_str = price_int_node.text.strip()
- price_decimal_node = self.d.xpath(price_decimal_xpath)
- if price_decimal_node.exists and price_decimal_node.text:
- price_str += price_decimal_node.text.strip()
- if not price_str:
- print('提取价格出错-->未获取到价格文本')
- return None
- # if self.d.xpath('//*[@text="优惠"]').exists:
- # self.d.xpath('//*[@text="优惠"]').click()
- # time.sleep(0.5)
- # if self.d.xpath('//*[contains(@text, "现在购买") and contains(@text, "享受以下优惠") and contains(@text, "共省")]').exists:
- # match = re.search(r'共省¥([\d.]+)', self.d.xpath('//*[contains(@text, "现在购买") and contains(@text, "享受以下优惠") and contains(@text, "共省")]').text)
- # self.d.press("back")
- # if match:
- # save_amount = match.group(1)
- # print(f"优惠金额: {save_amount}")
- # price = float(Decimal(str(price_str)) + Decimal(str(save_amount)))
- # if not price:
- # price = float(price_str)
- price = float(price_str)
- print(f'获取到价格: {price}')
- return price
- except Exception as e:
- print(f'提取价格出错-->{e}')
- return None
- def drug_sale_num(self):
- """
- 获取药品销量
- :return:
- """
- try:
- sales_element = self.d.xpath('//*[starts-with(@text,"已售")]')
- if sales_element.exists:
- sales_num_str = self.d.xpath('//*[starts-with(@text,"已售")]').text
- sales_num_str = sales_num_str.replace("已售", "").strip()
- # price = float(re.search(r'[\d\.]+', price_str).group())
- print(f'获取到已售数量:{sales_num_str}')
- return sales_num_str
- return None
- except Exception as e:
- print(f'提取已售数量出错-->{e}')
- return None
- def restart_uiautomator_services(self, device_id):
- """
- 重启atx的uiautomator 服务
- :param device_id:
- :return:
- """
- stop_uiautomator_services = f'adb -s {device_id} shell /data/local/tmp/atx-agent server -d --stop'
- start_uiautomator_services = f'adb -s {device_id} shell /data/local/tmp/atx-agent server -d'
- subprocess.run(stop_uiautomator_services, capture_output=True, text=True, shell=True)
- time.sleep(self.get_sleep_time())
- subprocess.run(start_uiautomator_services, capture_output=True, text=True, shell=True)
- time.sleep(self.get_sleep_time())
- def reconnect_device(self):
- """重启 atx-agent 并重新连接设备"""
- try:
- # 停止 atx-agent
- subprocess.run(["adb", "-s", self.device_id, "shell",
- "/data/local/tmp/atx-agent", "server", "-d", "--stop"],
- capture_output=True, timeout=5)
- time.sleep(1)
- # 启动 atx-agent
- subprocess.run(["adb", "-s", self.device_id, "shell",
- "/data/local/tmp/atx-agent", "server", "-d"],
- capture_output=True, timeout=5)
- time.sleep(2)
- # 重新连接 uiautomator2
- self.d = u2.connect_usb(self.device_id)
- self.is_high_res = is_high_resolution_device(self.d)
- self.restart_uiautomator_services(self.device_id)
- self.loggerMT.info("设备重连成功")
- return True
- except Exception as e:
- self.loggerMT.error(f"设备重连失败: {e}")
- return False
- def connect_devices(self, device_id):
- """
- 连接设备
- :return:
- """
- try:
- self.device_id = device_id
- self.d = u2.connect_usb(device_id)
- self.is_high_res = is_high_resolution_device(self.d)
- self.restart_uiautomator_services(device_id)
- self.oss_config = {
- "access_key_id": 'LTAI5t5pWgfa1BMztEuWBjdK',
- "access_key_secret": 'wU7FLzEr1NqLg2rJrmAu7Ibn69np0u',
- "endpoint": "oss-cn-shenzhen.aliyuncs.com", # 例:oss-cn-beijing.aliyuncs.com
- "bucket_name": "zhijiayun-jiansuo",
- "oss_prefix": "scrape_data/" # OSS中存放截图的前缀(虚拟文件夹)
- }
- print(f'连接到设备:{device_id}')
- self.loggerMT.info(f'连接到设备:{device_id}')
- except Exception as e:
- print(f'{device_id} 连接错误: {e}')
- self.loggerMT.info(f'{device_id} 连接错误: {e}')
- raise Exception(e)
- def get_ocr_res(self, img):
- try:
- # img地址
- print(f'开始识别图片:{img}')
- request_url = "https://aip.baidubce.com/rest/2.0/ocr/v1/business_license"
- f = open(img, 'rb')
- img = base64.b64encode(f.read())
- params = {"image": img}
- request_url = request_url + "?access_token=" + self.access_token
- headers = {'content-type': 'application/x-www-form-urlencoded'}
- response = requests.post(request_url, data=params, headers=headers)
- if response:
- res = response.json()
- new_dic = dict()
- for ite in res['words_result'].keys():
- new_dic[ite] = res['words_result'][ite]['words']
- print('资质数据信息', new_dic)
- return new_dic
- else:
- return None
- except:
- return None
- def remove_watermark(self, img_path):
- """
- 图片去水印(将水印部分变成白色背景)并将数据转化为二进制数据
- :param img_path: 图片路径
- :return: 二进制图片数据
- """
- img = cv2.imdecode(np.fromfile(img_path, dtype=np.uint8), -1)
- endswith = os.path.splitext(img_path)[1]
- new = np.clip(1.4057577998008846 * img - 38.33089999653017, 0, 255).astype(np.uint8)
- _, img_binary = cv2.imencode(endswith, new)
- return img_binary
- def get_ocr_res_image(self, img):
- try:
- image = self.remove_watermark(img)
- # image_file = open(img,'wb')
- # image_file.write(images)
- # res_image = self.client.basicAccurate(images) # 高精度
- res_image = self.client.basicGeneral(image)
- data = res_image.get('words_result', '')
- print(f'百度api返回结果:{data}')
- return data
- except:
- return None
- def write_ocr_result_sidecar(self, image_path, ocr_res, source="business_license", extra=None):
- """
- 将 OCR 结果写到与图片同名的 sidecar 文件,便于逐图对照识别结果。
- """
- try:
- if not image_path:
- return ''
- image_path = Path(str(image_path))
- sidecar_path = image_path.with_suffix('.ocr.json')
- payload = {
- "image_path": str(image_path),
- "source": source,
- "success": bool(ocr_res),
- "ocr_result": ocr_res if isinstance(ocr_res, (dict, list)) else {},
- "extra": extra or {},
- "created_at": time.strftime('%Y-%m-%d %H:%M:%S')
- }
- sidecar_path.parent.mkdir(parents=True, exist_ok=True)
- with open(sidecar_path, 'w', encoding='utf-8') as f:
- json.dump(payload, f, ensure_ascii=False, indent=2)
- print(f'OCR结果已写入:{sidecar_path}')
- return str(sidecar_path)
- except Exception as e:
- print(f'写OCR结果文件失败:{e}')
- return ''
- def screenshot_the_business_license(self, qualification_number):
- screenshot_path = 'screenshot1.png'
- self.d.screenshot(screenshot_path)
- img = cv2.imread(screenshot_path)
- # 指定裁剪区域 (left, top, right, bottom)
- left = 0
- top = 1026
- right = 1220
- bottom = 1904
- cropped_img = img[top:bottom, left:right]
- # 创建目录
- SCREENSHOT_DIR = Path('screenshot') # 注意这里的变化和py文件同一级目录即可
- SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
- if qualification_number:
- # cropped_screenshot_path = 'D:\\work\\dfwy_spider\\drug_data\\mt\\screenshot\\' + qualification_number + '.png'
- cropped_screenshot_path = SCREENSHOT_DIR / f'{qualification_number}.png'
- else:
- cropped_screenshot_path = 'cropped_screenshot.png'
- cv2.imwrite(str(cropped_screenshot_path), cropped_img)
- return cropped_screenshot_path
- def screenshot_instruction(self):
- # 获取当前时间
- current_time = datetime.datetime.now()
- # 格式化为时分秒
- time_str = current_time.strftime("%H-%M-%S")
- # 生成随机的 8 位字符串
- random_str = secrets.token_hex(4) # 生成 4 个字节的随机字符串,转换为 8 位十六进制字符串
- screenshot_path = 'instructionscreenshot1-' + time_str + '-' + random_str + '.png'
- self.d.screenshot(screenshot_path)
- return screenshot_path
- def extract_specification(self, text):
- """提取药品规格信息"""
- # 方法1:简单去除到期信息
- pattern = r'^[^【]+'
- match = re.search(pattern, text)
- if match:
- return match.group(0).strip()
- return text
- # 获取商品title
- def get_title(self):
- def _inner():
- print(f'获取商品title时的搜索关键字:{self.title_key}')
- # 初始化
- drugs_name = ''
- specifications = ''
- title = ''
- # 循环的获取title为了有时间来处理人机验证
- for m in range(1, 6000):
- if self.d.xpath(f'//*[contains(@text, "{self.title_key}")]').exists:
- title = self.safe_exec(
- lambda: self.d.xpath(f'//*[contains(@text, "{self.title_key}")]').text
- )
- print(f"第{m}次获取title成功")
- break
- else:
- time.sleep(3)
- # return drugs_name, specifications
- title = title[1:] if title.startswith('0') else title
- print(f'获取到药品标题:{title}')
- # match = re.match(r'(\[[^\]]+\])(.*?)\s*((?:\d+\S*|\(.+))$', title)
- match = re.match(r'^(?:0?)?(?:\[([^\]]+)\])?\s*(.*?)\s*(\d+[^\s]+)$', title)
- if match:
- # drugs_name = match.group(1).strip() + match.group(2).strip()
- drugs_name = title
- specifications = match.group(3).strip()
- print("药品名:", drugs_name)
- print("规格:", specifications)
- # 如果品规中包含到期则需要再次的正则处理
- if '到期' in specifications:
- specifications = self.extract_specification(specifications)
- # print('完整药名:', drugs_name + specifications)
- return drugs_name, specifications
- else:
- print("没有匹配到预期格式")
- drugs_name = title
- specifications = ''
- return drugs_name, specifications
- # 用 safe_exec 包装内部逻辑,确保验证码阻塞
- return self.safe_exec(_inner)
- def enter_shop(self):
- """
- 进店,方便提取资质环境
- :return:
- """
- self.d.xpath('//*[@text="店铺"]').click()
- time.sleep(0.7)
- def enter_shoper(self):
- """
- 进入商家
- :return:
- """
- is_shoper_exists = 0
- for i in range(5):
- if self.d.xpath('//*[@text="商家"]').exists:
- print(f'第{i}次商家存在')
- is_shoper_exists = 1
- break
- else:
- print(f'第{i}次商家不存在')
- time.sleep(0.5)
- if is_shoper_exists == 1:
- self.d.xpath('//*[@text="商家"]').click()
- time.sleep(1)
- return True
- else:
- return False
- # 点击查看商家资质
- def scan_shoper_license(self):
- exist_shoper = 0
- for i in range(6):
- if self.d.xpath('//*[@text="查看商家资质"]').exists:
- print(f'第{i}次查看商家资质存在')
- exist_shoper = 1
- break
- else:
- print(f'第{i}次查看商家资质不存在')
- if exist_shoper == 1:
- self.d.xpath('//*[@text="查看商家资质"]').click()
- time.sleep(0.5)
- else:
- self.swipe_back(1)
- # 验证店铺信息是否在数据库中已存在
- def shop_is_exists_database(self, shop, platform=None):
- platform = str(platform or self.platform or '4').strip()
- query_sql = f"""
- SELECT 1 FROM {self.shop_table_name}
- WHERE shop = %s
- AND platform = %s
- LIMIT 1
- """
- return self.query_exists(query_sql, (shop, platform), "店铺查重")
- def query_one(self, sql, params, error_desc):
- conn = None
- try:
- conn = get_mysql()
- with conn.cursor() as cur:
- cur.execute(sql, params)
- row = cur.fetchone()
- if not row:
- return None
- columns = [item[0] for item in cur.description]
- return dict(zip(columns, row))
- except Exception as e:
- print(f"{error_desc}错误: {str(e)}")
- return None
- finally:
- if conn:
- conn.close()
- def get_shop_info_from_database(self, shop, platform=None):
- platform = str(platform or self.platform or '4').strip()
- query_sql = f"""
- SELECT shop, contact_address, qualification_number, business_license_company,
- business_license_address, province, city
- FROM {self.shop_table_name}
- WHERE shop = %s
- AND platform = %s
- ORDER BY scrape_date DESC
- LIMIT 1
- """
- return self.query_one(query_sql, (shop, platform), "查询店铺信息")
- def wait_for_ready(self, monitor, timeout=86400):
- """进入每一页前都先等验证码"""
- if monitor is None:
- return
- start = time.time()
- while monitor.pausing.is_set() and time.time() - start < timeout:
- time.sleep(1)
- def _wait_xpath_exists(self, xpath, timeout=25, interval=0.5):
- deadline = time.time() + timeout
- while time.time() < deadline:
- self.wait_for_ready(getattr(self, "monitor", None))
- try:
- if self.d.xpath(xpath).exists:
- return True
- except Exception:
- pass
- time.sleep(interval)
- return False
- def _click_xpath_when_ready(self, xpath, action_desc, timeout=25, sleep_after=None):
- if not self._wait_xpath_exists(xpath, timeout=timeout):
- raise RuntimeError(f"{action_desc}失败,未找到元素: {xpath}")
- self.safe_exec(lambda: self.d.xpath(xpath).click())
- if sleep_after is None:
- sleep_after = self.get_sleep_time()
- if sleep_after > 0:
- time.sleep(sleep_after)
- def safe_exec(self, func, *args, **kwargs):
- """
- 万能安全壳:执行 func 前检查验证码,
- 若监控线程已置位 pausing,则一直阻塞直到放行。
- """
- self.wait_for_ready(getattr(self, "monitor", None))
- max_retries = 3
- for attempt in range(max_retries):
- try:
- result = func(*args, **kwargs)
- # 若执行过程中触发验证码,返回前继续阻塞直到监控放行。
- self.wait_for_ready(getattr(self, "monitor", None))
- return result
- except http.client.RemoteDisconnected as e:
- self.loggerMT.error(f"连接断开 (尝试 {attempt + 1}/{max_retries}): {e}")
- if attempt == max_retries - 1:
- raise # 最后一次失败,向上抛出
- # 尝试重连
- if self.reconnect_device():
- self.loggerMT.info("重连成功,准备重试...")
- time.sleep(2) # 等待设备稳定
- continue
- else:
- self.loggerMT.error("重连失败,无法继续")
- raise
- except Exception as e:
- # 其他异常直接抛出
- raise
- def get_next_data(self, data, target):
- for i, item in enumerate(data):
- if item['words'] == target:
- if i + 1 < len(data):
- return data[i + 1]['words']
- return None
- def delete_instruction_screenshot(self, screenshot_path):
- # 删除截图文件
- try:
- os.remove(screenshot_path)
- print(f"截图文件已删除:{screenshot_path}")
- except FileNotFoundError:
- print(f"文件未找到,无法删除:{screenshot_path}")
- except Exception as e:
- print(f"删除文件时出错:{e}")
- def get_instructions_data(self, capture_only=False):
- """
- 确定有说明书之后,提取所有的说明书数据
- :return:
- """
- self.d.xpath('//*[@text="说明"]').click()
- time.sleep(0.3)
- if self.d.xpath('//*[@text="查看详细说明"]').exists:
- self.d.xpath('//*[@text="查看详细说明"]').click()
- else:
- view_all_xpath = self.find_xpath_with_swipes(
- ['//*[@text="查看全部"]'],
- swipe_direction='down',
- swipe_scale=0.3,
- max_swipes=3,
- found_desc='查看全部'
- )
- if view_all_xpath:
- self.d.xpath(view_all_xpath).click()
- else:
- res_data = {
- "有效期": '',
- "生产单位": '',
- "批准文号": ''
- }
- self.loggerMT.info('获取到的说明书信息为空。')
- return res_data
- for ii in range(3):
- if self.d.xpath('//*[@text="查看更多"]').exists:
- self.d.xpath('//*[@text="查看更多"]').click()
- time.sleep(0.3)
- break
- else:
- if self.is_high_res:
- self.d.swipe(200, 2000, 200, 300, 0.3)
- else:
- self.d.swipe(200, 1000, 200, 300, 0.3)
- for iii in range(3):
- if self.d.xpath('//*[@text="生产单位"]').exists and self.d.xpath('//*[@text="批准文号"]').exists:
- break
- else:
- if self.is_high_res:
- self.d.swipe(200, 2000, 200, 300, 0.3)
- else:
- self.d.swipe(200, 1300, 200, 300, 0.3)
- # self.d.swipe_ext("up", scale=0.3)
- instruction_path = self.screenshot_instruction()
- self.swipe_back(1)
- if capture_only:
- return {"screenshot_path": instruction_path}
- ocr_res = self.get_ocr_res_image(instruction_path)
- if ocr_res:
- # 获取有效期的下一个数据
- validity = self.get_next_data(ocr_res, '有效期')
- # 获取批准文号的下一个数据
- approval_number = self.get_next_data(ocr_res, '批准文号')
- # 获取生产单位的下一个数据
- manufacturer = self.get_next_data(ocr_res, '生产单位')
- else:
- validity = ''
- approval_number = ''
- manufacturer = ''
- res_data = {
- "有效期": validity,
- "生产单位": manufacturer,
- "批准文号": approval_number
- }
- print(f"res_data={res_data}")
- self.delete_instruction_screenshot(instruction_path)
- return res_data
- def has_instructions(self):
- """
- 是否有说明书
- :return:
- """
- # 没有说明书的无法采集具体数据
- return bool(self.find_xpath_with_swipes(
- ['//*[@text="说明"]'],
- swipe_direction='down',
- swipe_scale=0.3,
- max_swipes=4,
- found_desc='说明'
- ))
- def has_shop(self):
- """
- 是否有进店按钮
- :return:
- """
- is_has_enter_shop = self.d.xpath('//*[@text="进店"]').exists
- return is_has_enter_shop
- def get_license_info_capture(self):
- self.enter_shop()
- result = self.enter_shoper()
- if result is False:
- return {
- 'need_save': False,
- 'need_back': True,
- 'contact_address': '',
- 'qualification_number': '',
- 'business_license_image_path': ''
- }
- for _ in range(5):
- if self.d.xpath('//*[@text="查看商家资质"]').exists:
- break
- time.sleep(0.5)
- contact_address = self.get_shop_address() or ''
- self.scan_shoper_license()
- time.sleep(3)
- qualification_number = self.get_qualification_number() or ''
- business_license_image_path = ''
- if qualification_number:
- self.d.click(0.603, 0.27)
- time.sleep(1.5)
- captured = self.screenshot_the_business_license(qualification_number)
- business_license_image_path = str(captured) if captured else ''
- return {
- 'need_save': True,
- 'need_back': True,
- 'contact_address': contact_address,
- 'qualification_number': qualification_number,
- 'business_license_image_path': business_license_image_path
- }
- def distinct_target(self):
- list_page_xpaths = [
- '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[3]',
- '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[2]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[3]',
- '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.widget.ScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.support.v7.widget.RecyclerView[1]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.widget.HorizontalScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[last()]',
- '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.widget.ScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.support.v7.widget.RecyclerView[1]/android.widget.FrameLayout[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.widget.HorizontalScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[last()]',
- '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[2]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.support.v7.widget.RecyclerView[1]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.widget.HorizontalScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[last()]',
- '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[2]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.support.v7.widget.RecyclerView[1]/android.widget.FrameLayout[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.widget.HorizontalScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[last()]'
- ]
- exists_tasks = {
- f'list_xpath_{idx}': (lambda xp=xp: self.d.xpath(xp).exists)
- for idx, xp in enumerate(list_page_xpaths)
- }
- exists_results = self.run_parallel_tasks(exists_tasks)
- result = any(bool(v) for v in exists_results.values())
- if result == False:
- print("---检测没有回到列表页---")
- return result
- # return is_position
- def _target_flow_xpaths(self):
- return {
- "medical_entry": '//*[@content-desc="看病买药"]',
- "home_search_entry": '//*[@resource-id="com.sankuai.meituan:id/vf_search_carousel_text"]',
- "search_input": '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]',
- "search_button": '//*[@text="搜索"]',
- }
- def _detect_target_flow_stage(self):
- xpaths = self._target_flow_xpaths()
- if self.distinct_target():
- return "list_page"
- exists_tasks = {
- key: (lambda xp=xp: self.d.xpath(xp).exists)
- for key, xp in xpaths.items()
- }
- exists_results = self.run_parallel_tasks(exists_tasks)
- if exists_results.get("search_button"):
- return "search_input"
- if exists_results.get("search_input"):
- return "search_input"
- if exists_results.get("home_search_entry"):
- return "home_search_entry"
- if exists_results.get("medical_entry"):
- return "medical_entry"
- return None
- def _wait_until_list_page(self, timeout=15, interval=0.8):
- deadline = time.time() + timeout
- while time.time() < deadline:
- if self.distinct_target():
- return True
- time.sleep(interval)
- return False
- def _recover_to_list_page_from_target_flow(self):
- stage = self._detect_target_flow_stage()
- if stage is None:
- return False
- if stage == "list_page":
- return True
- xpaths = self._target_flow_xpaths()
- print(f"检测到已退回入口流程页,当前阶段: {stage},开始顺序恢复到列表页")
- if stage == "medical_entry":
- self._click_xpath_when_ready(
- xpaths["medical_entry"],
- "进入看病买药页",
- timeout=20
- )
- stage = "home_search_entry"
- if stage == "home_search_entry":
- self._click_xpath_when_ready(
- xpaths["home_search_entry"],
- "点击首页搜索入口",
- timeout=30
- )
- stage = "search_input"
- if stage == "search_input":
- self._click_xpath_when_ready(
- xpaths["search_input"],
- "点击搜索输入框",
- timeout=20,
- sleep_after=0.5
- )
- self.safe_exec(lambda: self.d.send_keys(self.search_key, clear=True))
- time.sleep(0.5)
- self._click_xpath_when_ready(
- xpaths["search_button"],
- "点击搜索按钮",
- timeout=20
- )
- self.safe_exec(self.click_express_send)
- time.sleep(0.5)
- self.wr_re("读", self.device_id)
- if self._wait_until_list_page(timeout=18, interval=0.8):
- print("已从入口流程页恢复到列表页")
- return True
- print("入口流程页恢复后仍未到列表页")
- return False
- def enter_target_page(self):
- self._click_xpath_when_ready(
- '//*[@content-desc="看病买药"]',
- "进入看病买药页",
- timeout=20
- )
- self._click_xpath_when_ready(
- '//*[@resource-id="com.sankuai.meituan:id/vf_search_carousel_text"]',
- "点击首页搜索入口",
- timeout=30
- )
- search_input_xpaths = [
- '//*[@resource-id="com.sankuai.meituan:id/dynamic_hint_layout"]',
- '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]',
- ]
- for search_input_xpath in search_input_xpaths:
- if self._wait_xpath_exists(search_input_xpath, timeout=20):
- self._click_xpath_when_ready(
- search_input_xpath,
- "点击搜索输入框",
- timeout=20,
- sleep_after=0.5
- )
- break
- else:
- raise RuntimeError(f"点击搜索输入框失败,未找到元素: {search_input_xpaths}")
- self.safe_exec(lambda: self.d.send_keys(self.search_key, clear=True))
- time.sleep(0.5)
- self._click_xpath_when_ready(
- '//*[@text="搜索"]',
- "点击搜索按钮",
- timeout=20
- )
- self.safe_exec(self.click_express_send)
- time.sleep(0.5)
- self.wr_re("读", self.device_id)
- time.sleep(2)
- def click_express_send(self):
- slide_xpaths = [
- '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.widget.ScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.support.v7.widget.RecyclerView[1]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.widget.HorizontalScrollView[1]',
- '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.widget.ScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.support.v7.widget.RecyclerView[1]/android.widget.FrameLayout[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.widget.HorizontalScrollView[1]',
- '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[2]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.support.v7.widget.RecyclerView[1]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.widget.HorizontalScrollView[1]',
- '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[2]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.support.v7.widget.RecyclerView[1]/android.widget.FrameLayout[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.widget.HorizontalScrollView[1]',
- ]
- for i in range(1, 3):
- self.wait_for_ready(getattr(self, "monitor", None))
- matched_slide_xpath = self.get_first_existing_xpath(slide_xpaths)
- if not matched_slide_xpath:
- time.sleep(self.get_sleep_time())
- continue
- bounds = self.d.xpath(matched_slide_xpath).info['bounds']
- top = bounds['top']
- bottom = bounds['bottom']
- print(f'top={top}')
- print(f'bottom={bottom}')
- y = (top + bottom) // 2
- print(f'y={y}')
- self.loggerMT.info(f'开始滑动{i}')
- self.safe_exec(lambda: self.d.swipe(500, y, 100, y, 0.5))
- time.sleep(self.get_sleep_time())
- break
- express_send_xpaths = [
- '//*[@text="快递送"]',
- '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.widget.ScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.support.v7.widget.RecyclerView[1]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.widget.HorizontalScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[last()]',
- '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.widget.ScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.support.v7.widget.RecyclerView[1]/android.widget.FrameLayout[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.widget.HorizontalScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[last()]',
- '//*[@resource-id="com.sankuai.meituan:id/container"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[2]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.support.v7.widget.RecyclerView[1]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.widget.HorizontalScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[last()]',
- '//androidx.recyclerview.widget.RecyclerView/android.widget.FrameLayout[3]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.widget.HorizontalScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[3]/android.view.ViewGroup[1]/android.widget.FrameLayout[1]',
- '//androidx.recyclerview.widget.RecyclerView/android.widget.FrameLayout[3]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.widget.HorizontalScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[3]/android.view.ViewGroup[1]/android.view.ViewGroup[1]',
- '//androidx.recyclerview.widget.RecyclerView/android.widget.FrameLayout[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.widget.HorizontalScrollView[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[3]/android.view.ViewGroup[1]/android.widget.FrameLayout[1]',
- ]
- self.click_candidate_xpaths(
- express_send_xpaths,
- action_desc="点击快递送",
- max_retries=5,
- sleep_after=self.get_sleep_time(),
- )
- def get_clipboard(self):
- time.sleep(0.5)
- clipboard_content = self.d.clipboard
- if clipboard_content is None:
- return ''
- return clipboard_content.strip()
- def get_product_link(self):
- try:
- product_link = ''
- self.safe_exec(self.d.xpath(
- '//android.widget.ScrollView/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]').click)
- if self._check_request_error_after_click():
- print('进入商品链接分享失败,目前没测试原因')
- self.back_to_list_page()
- return product_link
- max_retry = 5 # 最多尝试次数
- for idx in range(1, max_retry + 1):
- time.sleep(random.uniform(0.8, 1))
- x = int(random.uniform(0.2, 0.7) * 720)
- y = int(random.uniform(0.4, 0.7) * 1640)
- self.d.touch.down(x, y)
- time.sleep(random.uniform(0.45, 0.65))
- self.d.touch.up(x, y)
- if self.d(resourceId="com.sankuai.meituan:id/share_title").exists:
- time.sleep(1.5)
- save_png = str(self.device_id) + '.png'
- self.d.screenshot(save_png)
- product_link = decode_qr(save_png)
- # 清洗:从元组中提取纯链接
- if isinstance(product_link, (tuple, list)):
- product_link = str(product_link[0]) if product_link else ''
- else:
- product_link = str(product_link or '')
- # 只要合法的美团二维码链接
- if not product_link.startswith('https://'):
- product_link = ''
- print(f'{idx}-商品链接:{product_link}')
- self.loggerMT.info(f'{idx}-商品链接:{product_link}')
- break
- if not product_link and idx < max_retry:
- time.sleep(0.1) # 最后一次不需要再等待
- try:
- if product_link:
- os.makedirs('save_png', exist_ok=True)
- os.makedirs('error', exist_ok=True)
- match = re.search(r'[?&]scPid=([^&\s]+)', product_link)
- if match:
- code = match.group(1)
- self.d.screenshot('save_png/'+code+'.png')
- else:
- self.d.screenshot('error/'+str(time.time())[-7:]+'.png')
- except Exception as e:
- print(e)
- if product_link == '':
- self.swipe_back(1)
- else:
- self.swipe_back(2)
- return product_link
- except Exception as e:
- raise RuntimeError(f"get_product_link 失败: {e}")
- def run_parallel_tasks(self, task_map):
- """
- 并行执行相互独立的只读任务。
- 任务本身不能包含点击、滑动、返回等会改变页面状态的操作。
- """
- if not task_map:
- return {}
- results = {}
- with ThreadPoolExecutor(max_workers=len(task_map)) as executor:
- future_map = {
- task_name: executor.submit(self.safe_exec, task_func)
- for task_name, task_func in task_map.items()
- }
- for task_name, future in future_map.items():
- try:
- results[task_name] = future.result()
- except Exception as e:
- print(f'并行采集任务 {task_name} 执行失败: {e}')
- results[task_name] = None
- return results
- def _cleanup_post_process_futures(self):
- with self.post_process_lock:
- self.post_process_futures = [f for f in self.post_process_futures if not f.done()]
- def _wait_for_post_task_slot(self):
- while True:
- self._cleanup_post_process_futures()
- with self.post_process_lock:
- pending_count = len(self.post_process_futures)
- futures_snapshot = list(self.post_process_futures)
- if pending_count < self.max_pending_post_tasks:
- return
- if not futures_snapshot:
- return
- wait(futures_snapshot, timeout=5, return_when=FIRST_COMPLETED)
- def _submit_post_process_task(self, save_data, instruction_screenshot_path='', shop_payload=None):
- self._cleanup_post_process_futures()
- payload = dict(save_data)
- shop_payload_copy = dict(shop_payload) if isinstance(shop_payload, dict) else None
- future = self.post_process_executor.submit(
- self._async_finalize_and_store_data,
- payload,
- instruction_screenshot_path,
- shop_payload_copy
- )
- with self.post_process_lock:
- self.post_process_futures.append(future)
- def _wait_post_process_tasks(self):
- with self.post_process_lock:
- futures_snapshot = list(self.post_process_futures)
- for future in futures_snapshot:
- try:
- future.result()
- except Exception as e:
- print(f'后台任务异常: {e}')
- self._cleanup_post_process_futures()
- def _shutdown_post_process_executor(self):
- try:
- self.post_process_executor.shutdown(wait=True)
- except Exception as e:
- print(f'关闭后台线程池异常: {e}')
- def _async_finalize_and_store_data(self, save_data, instruction_screenshot_path='', shop_payload=None):
- final_data = dict(save_data)
- try:
- if instruction_screenshot_path and os.path.exists(instruction_screenshot_path):
- ocr_res = self.get_ocr_res_image(instruction_screenshot_path)
- if ocr_res:
- validity = self.get_next_data(ocr_res, '有效期')
- manufacturer = self.get_next_data(ocr_res, '生产单位')
- approval_number = self.get_next_data(ocr_res, '批准文号')
- if validity:
- final_data['expiry_date'] = str(validity).strip('。')
- if manufacturer:
- final_data['manufacturer'] = str(manufacturer).strip('。')
- if approval_number:
- final_data['approval_number'] = str(approval_number).strip('。')
- except Exception as e:
- print(f'后台处理说明书OCR异常: {e}')
- finally:
- if instruction_screenshot_path:
- self.delete_instruction_screenshot(instruction_screenshot_path)
- try:
- if isinstance(shop_payload, dict) and shop_payload.get("need_save"):
- company = str(shop_payload.get('business_license_company') or '').strip()
- address = str(shop_payload.get('business_license_address') or '').strip()
- contact_address = str(shop_payload.get('contact_address') or address).strip()
- province = str(shop_payload.get('province') or '').strip()
- city = str(shop_payload.get('city') or '').strip()
- biz_img = shop_payload.get("business_license_image_path", "")
- ocr_res = None
- should_ocr = (not company)
- if ENABLE_SHOP_DEBUG:
- print(
- f"[SHOP-DEBUG] async shop payload: shop={shop_payload.get('shop', '')}, "
- f"should_ocr={should_ocr}, company={company}, contact_address={contact_address}, "
- f"biz_address={address}, province={province}, city={city}, biz_img_exists={bool(biz_img and os.path.exists(biz_img))}"
- )
- if should_ocr and biz_img and os.path.exists(biz_img):
- ocr_res = self.get_ocr_res(biz_img)
- if ocr_res:
- company = ocr_res.get('单位名称', '') if isinstance(ocr_res, dict) else ''
- address = ocr_res.get('地址', '') if isinstance(ocr_res, dict) else ''
- contact_address = str(address or '').strip()
- if ENABLE_SHOP_DEBUG:
- print(f"[SHOP-DEBUG] OCR结果: company={company}, biz_address={address}")
- if biz_img and os.path.exists(biz_img):
- self.write_ocr_result_sidecar(
- biz_img,
- ocr_res if should_ocr else {},
- source="business_license",
- extra={
- "shop": shop_payload.get('shop', ''),
- "qualification_number": shop_payload.get('qualification_number', ''),
- "should_ocr": should_ocr,
- "attempted": bool(should_ocr),
- "parsed_company": company,
- "parsed_address": address,
- "contact_address": contact_address
- }
- )
- if not address and contact_address:
- address = contact_address
- if not contact_address and address:
- contact_address = address
- if (not province) or (not city):
- match = self.area_service.search_area(address or contact_address)
- if match:
- province = province or match.province
- city = city or match.city
- if ENABLE_SHOP_DEBUG:
- print(
- f"[SHOP-DEBUG] region推断后: province={province}, city={city}, by_address={address or contact_address}")
- save_shop_data = {
- 'shop': shop_payload.get('shop', ''),
- 'contact_address': contact_address,
- 'qualification_number': shop_payload.get('qualification_number', ''),
- 'scrape_date': shop_payload.get('scrape_date', self.get_current_date()),
- 'business_license_company': company,
- 'business_license_address': address,
- 'platform': str(shop_payload.get('platform') or self.platform),
- 'province': province,
- 'city': city,
- 'create_time': time.strftime('%Y-%m-%d %H:%M:%S'),
- 'update_time': time.strftime('%Y-%m-%d %H:%M:%S')
- }
- self.save_shop_info_to_database(save_shop_data)
- # 用 area_service 获取 province_id / city_id
- match = self.area_service.search_area(province + city) if (province or city) else None
- derived_province_id = match.province_id if match else 0
- derived_city_id = match.city_id if match else 0
- if ENABLE_SHOP_DEBUG:
- print(f"[SHOP-DEBUG] derived ids: province_id={derived_province_id}, city_id={derived_city_id}")
- if not final_data.get('company_name'):
- final_data['company_name'] = company
- if not final_data.get('qualification_number'):
- final_data['qualification_number'] = str(shop_payload.get('qualification_number', '') or '').strip()
- if province:
- final_data['province_name'] = province
- if city:
- final_data['city_name'] = city
- if derived_province_id:
- final_data['province_id'] = derived_province_id
- if derived_city_id:
- final_data['city_id'] = derived_city_id
- if ENABLE_SHOP_DEBUG:
- print(
- f"[SHOP-DEBUG] final_data回填: company_name={final_data.get('company_name')}, "
- f"qualification_number={final_data.get('qualification_number')}, "
- f"province_name={final_data.get('province_name')}, city_name={final_data.get('city_name')}, "
- f"province_id={final_data.get('province_id')}, city_id={final_data.get('city_id')}"
- )
- except Exception as e:
- print(f'后台处理店铺OCR异常: {e}')
- print(final_data)
- saved = self.save_to_database(final_data)
- if saved:
- current_count = self.mark_collected_data_saved()
- if self.count is not None:
- print(f"当前已成功采集 {current_count}/{self.count} 条")
- def get_available_xpaths(self, xpaths):
- check_tasks = {
- f'xpath_{idx}': (lambda xp=xp: self.d.xpath(xp).exists)
- for idx, xp in enumerate(xpaths)
- }
- exists_results = self.run_parallel_tasks(check_tasks)
- return [
- xpath for idx, xpath in enumerate(xpaths)
- if exists_results.get(f'xpath_{idx}')
- ]
- def get_first_existing_xpath(self, xpaths):
- available_xpaths = self.get_available_xpaths(xpaths)
- if not available_xpaths:
- return None
- return available_xpaths[0]
- def get_first_text_by_xpaths(self, xpaths):
- text_tasks = {
- f'xpath_{idx}': (lambda xp=xp: self._read_xpath_text(xp))
- for idx, xp in enumerate(xpaths)
- }
- text_results = self.run_parallel_tasks(text_tasks)
- for idx, _ in enumerate(xpaths):
- text = text_results.get(f'xpath_{idx}')
- if text:
- return text
- return ''
- def get_first_texts_by_xpath_groups(self, xpath_groups):
- tasks = {}
- group_keys = {}
- for group_name, xpaths in xpath_groups.items():
- group_keys[group_name] = []
- for idx, xpath in enumerate(xpaths):
- task_name = f'{group_name}_{idx}'
- group_keys[group_name].append(task_name)
- tasks[task_name] = (lambda xp=xpath: self._read_xpath_text(xp))
- text_results = self.run_parallel_tasks(tasks)
- grouped_results = {}
- for group_name, task_names in group_keys.items():
- grouped_results[group_name] = ''
- for task_name in task_names:
- text = text_results.get(task_name)
- if text:
- grouped_results[group_name] = text
- break
- return grouped_results
- def _read_xpath_text(self, xpath):
- selector = self.d.xpath(xpath)
- if not selector.exists:
- return ''
- try:
- text = selector.text
- return text.strip() if isinstance(text, str) else text
- except Exception:
- return ''
- def click_candidate_xpaths(self, xpaths, action_desc, max_retries=1, sleep_after=0):
- for attempt in range(1, max_retries + 1):
- available_xpaths = self.get_available_xpaths(xpaths)
- if not available_xpaths:
- print(f'{action_desc}失败,第{attempt}次没有匹配到可点击的xpath')
- time.sleep(self.get_sleep_time())
- continue
- rotate_offset = (attempt - 1) % len(available_xpaths)
- candidate_xpaths = available_xpaths[rotate_offset:] + available_xpaths[:rotate_offset]
- for xpath in candidate_xpaths:
- try:
- self.safe_exec(lambda xp=xpath: self.d.xpath(xp).click())
- print(f'{action_desc}成功')
- if sleep_after:
- time.sleep(sleep_after)
- return xpath
- except Exception as e:
- print(f'{action_desc}点击异常: {e}')
- time.sleep(0.5)
- return None
- def find_xpath_with_swipes(self, xpaths, swipe_direction='down', swipe_scale=0.3, max_swipes=8, found_desc=''):
- for idx in range(max_swipes):
- matched_xpath = self.get_first_existing_xpath(xpaths)
- if matched_xpath:
- if found_desc:
- print(f'第{idx}次找到{found_desc}')
- return matched_xpath
- self.d.swipe_ext(swipe_direction, swipe_scale)
- matched_xpath = self.get_first_existing_xpath(xpaths)
- if matched_xpath and found_desc:
- print(f'第{max_swipes}次找到{found_desc}')
- return matched_xpath
- def _collect_detail_core_data(self, prefetched, ctx):
- """步骤1: 采集详情页核心字段(标题、价格、销量、自营状态)"""
- prefetched = prefetched if isinstance(prefetched, dict) else {}
- prefetched_product = str(prefetched.get("product") or "").strip()
- prefetched_specifications = str(prefetched.get("specifications") or "").strip()
- prefetched_shop = str(prefetched.get("shop") or "").strip()
- detail_tasks = {
- "sales_num": self.drug_sale_num,
- "is_self_operated": lambda: self.d.xpath('//*[@text="自营"]').exists,
- "min_price": self.drug_price,
- }
- if not prefetched_product:
- detail_tasks["title_info"] = self.get_title
- detail_data = self.run_parallel_tasks(detail_tasks)
- if not prefetched_shop:
- detail_data["shop_inline"] = self.safe_exec(self.get_shop_name_from_current_page)
- if prefetched_product:
- product = prefetched_product
- specifications = prefetched_specifications
- else:
- title_info = detail_data.get("title_info")
- if isinstance(title_info, (list, tuple)) and len(title_info) >= 2:
- product, specifications = title_info[0], title_info[1]
- else:
- product, specifications = "", ""
- if not product:
- self.swipe_back(1)
- return False
- min_price = detail_data.get("min_price")
- if min_price in (None, ""):
- print("详情页未获取到价格,返回列表页采集下一条")
- return False
- if self.collect_range:
- range_start = self.collect_range["start"]
- range_end = self.collect_range["end"]
- if not (range_start <= min_price <= range_end):
- print(f"detail price {min_price} not in range {range_start}-{range_end}, skip")
- return False
- ctx.update({
- "detail_data": detail_data,
- "product": product,
- "specifications": specifications,
- "min_price": min_price,
- "shop": prefetched_shop or (detail_data.get("shop_inline") or "").strip(),
- "sales_num": detail_data.get("sales_num"),
- "scrape_date": self.get_current_date(),
- "product_link": "",
- "shop_async_payload": None,
- "shop_db_info": None,
- "need_collect_shop_ocr": False,
- "instruction_screenshot_path": "",
- "manufacture_date": "",
- "expiry_date": "",
- "manufacturer": "",
- "approval_number": "",
- })
- return True
- def _is_official_shop(self, shop="", detail_data=None):
- shop = str(shop or "").strip()
- detail_data = detail_data if isinstance(detail_data, dict) else {}
- return bool(
- detail_data.get("is_self_operated")
- or ("美团官方" in shop)
- or ("美团自营" in shop)
- )
- def _handle_detail_shop(self, ctx):
- """步骤2: 处理店铺、店铺数据库信息与商品链接"""
- detail_data = ctx.get("detail_data") or {}
- product = ctx["product"]
- min_price = ctx["min_price"]
- scrape_date = ctx["scrape_date"]
- ctx["platform_item_id"] = ctx["shop"] + product
- if detail_data.get("is_self_operated"):
- ctx["shop"] = "美团自营大药房(快递电商)"
- ctx["platform_item_id"] = ctx["shop"] + product
- self._reset_product_link_missing_counter()
- return True
- if not ctx["shop"]:
- self.find_xpath_with_swipes(
- ['//*[@text="进店"]'],
- swipe_direction='up',
- swipe_scale=0.3,
- max_swipes=8,
- found_desc='进店'
- )
- ctx["shop"] = (self.get_shop_name_from_current_page() or self.get_shop_name() or "").strip()
- if not ctx["shop"]:
- print('未获取到店铺名:开始回退')
- self.back_to_list_page()
- return False
- ctx["platform_item_id"] = ctx["shop"] + product
- db_check_tasks = {
- "shop_exists": lambda: self.shop_is_exists_database(ctx["shop"], self.platform),
- }
- db_check_results = self.run_parallel_tasks(db_check_tasks)
- shop_db_info = None
- shop_is_exists = bool(db_check_results.get("shop_exists"))
- if shop_is_exists:
- shop_db_info = self.get_shop_info_from_database(ctx["shop"], self.platform)
- company = str((shop_db_info or {}).get("business_license_company") or "").strip()
- biz_address = str((shop_db_info or {}).get("business_license_address") or "").strip()
- contact = str((shop_db_info or {}).get("contact_address") or "").strip()
- need_collect_shop_ocr = (not shop_is_exists) or (not company)
- if ENABLE_SHOP_DEBUG:
- print(
- f"[SHOP-DEBUG] should_collect_shop_ocr: "
- f"company_empty={not bool(company)}, biz_address_empty={not bool(biz_address)}, contact_empty={not bool(contact)}"
- )
- print(
- f"[SHOP-DEBUG] 主流程店铺判定: shop={ctx['shop']}, "
- f"shop_is_exists={shop_is_exists}, need_collect_shop_ocr={need_collect_shop_ocr}, "
- f"shop_db_info={shop_db_info}"
- )
- ctx["shop_db_info"] = shop_db_info
- ctx["need_collect_shop_ocr"] = need_collect_shop_ocr
- ctx["product_link"] = self._record_product_link_result(self.get_product_link())
- if not ctx["product_link"]:
- print("当前商品获取不到商品链接,返回列表页")
- self.back_to_list_page()
- return False
- return True
- def _collect_detail_instruction_info(self, ctx):
- """步骤3: 采集说明书截图信息"""
- if not self.safe_exec(self.has_instructions):
- return
- print('开始获取说明书信息')
- try:
- instructions_info = self.safe_exec(lambda: self.get_instructions_data(capture_only=True))
- if isinstance(instructions_info, dict):
- ctx["instruction_screenshot_path"] = instructions_info.get("screenshot_path", "")
- except Exception as e:
- print(f'说明书采集跳过: {e}')
- def _collect_detail_shop_ocr(self, ctx):
- """步骤4: 采集店铺资质 OCR"""
- if self._is_official_shop(ctx.get("shop", ""), ctx.get("detail_data")):
- return
- print(f"已采集{self.shop_data_num}家店铺数据")
- is_has_enter_shop = bool(self.safe_exec(self.has_shop))
- shop = ctx["shop"]
- if is_has_enter_shop and ctx["need_collect_shop_ocr"] and self.shop_data_num < 500:
- license_capture = self.safe_exec(self.get_license_info_capture)
- if license_capture.get("need_back"):
- self.swipe_back(2)
- ctx["shop_async_payload"] = {
- "need_save": bool(license_capture.get("need_save")),
- "shop": shop,
- "contact_address": license_capture.get("contact_address", ""),
- "qualification_number": license_capture.get("qualification_number", ""),
- "business_license_image_path": license_capture.get("business_license_image_path", ""),
- "scrape_date": ctx["scrape_date"],
- "platform": str(self.platform),
- }
- if ENABLE_SHOP_DEBUG:
- print(f"[SHOP-DEBUG] 走OCR采集分支, shop_async_payload={ctx['shop_async_payload']}")
- if ctx["shop_async_payload"]["need_save"]:
- self.shop_data_num += 1
- else:
- print('不采集店铺信息')
- def _build_detail_save_data(self, ctx):
- """步骤5: 组装详情页保存数据"""
- shop_db_info = ctx.get("shop_db_info")
- shop = ctx["shop"]
- province_id, city_id, province, city = 0, 0, '', ''
- # 优先从 DB 缓存取,没有则从地址中推断
- addr_for_region = ''
- if isinstance(shop_db_info, dict):
- province = str(shop_db_info.get('province') or '').strip()
- city = str(shop_db_info.get('city') or '').strip()
- addr_for_region = str(
- shop_db_info.get('business_license_address')
- or shop_db_info.get('contact_address')
- or ''
- ).strip()
- if (not province or not city) and addr_for_region:
- match = self.area_service.search_area(addr_for_region)
- if match:
- province_id, city_id = match.province_id, match.city_id
- province = province or match.province
- city = city or match.city
- elif province or city:
- # DB 已有省市,只查 ID
- match = self.area_service.search_area(province + city)
- if match:
- province_id, city_id = match.province_id, match.city_id
- if ENABLE_SHOP_DEBUG:
- print(
- f"[SHOP-DEBUG] save_data省市计算: shop={shop}, province={province}, city={city}, "
- f"province_id={province_id}, city_id={city_id}"
- )
- save_data = {
- 'enterprise_id': self.enterprise_id,
- 'platform_id': 4,
- 'platform_item_id': ctx["platform_item_id"],
- 'province_id': province_id,
- 'city_id': city_id,
- 'province_name': '',
- 'city_name': '',
- 'area_info': "",
- 'product_brand': self.brand,
- 'product_name': ctx["product"],
- 'product_specs': ctx["specifications"],
- 'one_box_price': 0.00,
- 'manufacture_date': ctx["manufacture_date"],
- 'expiry_date': ctx["expiry_date"],
- 'manufacturer': ctx["manufacturer"],
- 'approval_number': ctx["approval_number"],
- 'is_sold_out': 0,
- 'online_posting_count': 1,
- 'continuous_listing_count': 1,
- 'link_url': ctx["product_link"],
- 'store_name': shop,
- 'store_url': '',
- 'shipment_province_id': 0,
- 'shipment_province_name': "",
- 'shipment_city_id': 0,
- 'shipment_city_name': "",
- 'company_name': "",
- 'qualification_number': "",
- 'search_name': self.search_key,
- 'scrape_date': ctx["scrape_date"],
- 'min_price': ctx["min_price"],
- 'number': 1,
- 'sales': ctx["sales_num"],
- 'inventory': "",
- 'snapshot_url': str(ctx.get("snapshot_url") or ""),
- 'collect_equipment_account_id': self.collect_equipment_account_id,
- 'collect_region_id': self.collect_region_id,
- 'collect_round': self.collect_round,
- 'insert_time': time.strftime('%Y-%m-%d %H:%M:%S'),
- 'update_time': time.strftime('%Y-%m-%d %H:%M:%S'),
- 'collect_config_info': {
- "sampling_cycle": self.sampling_cycle,
- "sampling_start_time": self.sampling_start_time,
- "sampling_end_time": self.sampling_end_time,
- },
- 'task_id': self.task_id,
- }
- if isinstance(shop_db_info, dict):
- save_data['province_name'] = province
- save_data['city_name'] = city
- save_data['province_id'] = province_id
- save_data['city_id'] = city_id
- save_data['company_name'] = str(shop_db_info.get('business_license_company') or '')
- save_data['qualification_number'] = str(shop_db_info.get('qualification_number') or '')
- if ENABLE_SHOP_DEBUG:
- print(
- f"[SHOP-DEBUG] save_data DB回填结果: company_name={save_data['company_name']}, "
- f"qualification_number={save_data['qualification_number']}, "
- f"province_name={save_data['province_name']}, city_name={save_data['city_name']}, "
- f"province_id={save_data['province_id']}, city_id={save_data['city_id']}"
- )
- if isinstance(save_data.get('collect_config_info'), dict):
- save_data['collect_config_info'] = json.dumps(save_data['collect_config_info'], ensure_ascii=False)
- ctx["save_data"] = save_data
- return save_data
- def _submit_detail_record(self, ctx):
- """步骤6: 提交后台处理并回到列表页"""
- self._submit_post_process_task(
- ctx["save_data"],
- instruction_screenshot_path=ctx["instruction_screenshot_path"],
- shop_payload=ctx["shop_async_payload"]
- )
- print(f'[{datetime.datetime.now().strftime("%H:%M:%S.%f")}] 已提交后台异步处理OCR与入库')
- print(f'[{datetime.datetime.now().strftime("%H:%M:%S.%f")}] 开始回列表页')
- return self.back_to_list_page()
- def integrate_data(self, prefetched=None):
- """
- 整合详情页数据
- """
- ctx = {}
- prefetched_title = ""
- if isinstance(prefetched, dict):
- prefetched_title = str(prefetched.get("product") or "").strip()
- mt_screenshot = MTScreenshot(
- d=self.d,
- oss_config=self.oss_config,
- search_key=self.search_key, # 添加这行
- title_key=self.title_key,
- device_id=self.device_id,
- monitor=getattr(self, "monitor", None)
- )
- # 1. 采集详情页核心字段
- if not self._collect_detail_core_data(prefetched, ctx):
- return
- # 2. 处理店铺、去重、店铺数据库信息与商品链接
- if not self._handle_detail_shop(ctx):
- return
- # 3. 采集说明书截图
- self._collect_detail_instruction_info(ctx)
- # 4.网页快照
- snapshot_url = mt_screenshot.get_oss_url(title=prefetched_title)
- ctx["snapshot_url"] = str(snapshot_url or "")
- # 5. 采集店铺资质 OCR
- self._collect_detail_shop_ocr(ctx)
- # 6. 组装保存数据
- self._build_detail_save_data(ctx)
- # 7. 提交后台处理并回列表页
- return self._submit_detail_record(ctx)
- def back_to_list_page(self):
- for i in range(5):
- if self.distinct_target():
- return True
- if i >= 3 and self._recover_to_list_page_from_target_flow():
- return True
- print(f'第{i}次尝试退回到列表页')
- self.swipe_back(1)
- time.sleep(0.5)
- if self._recover_to_list_page_from_target_flow():
- return True
- print('页面出错,没有退回到列表页')
- return False
- def reset_collection_cursor(self):
- self.collection_cursor["page_no"] = 1
- self.collection_cursor["item_index"] = 0
- def get_current_page_no(self):
- return self.page + self.collection_cursor["page_no"]
- def jump_to_page(self, target_page):
- current_page = self.get_current_page_no()
- if target_page <= current_page:
- return
- while current_page < target_page:
- if self.d.xpath('//*[@text="已经到底啦"]').exists:
- print(f"列表实际页数不足,当前停留在第{current_page}页,无法跳转到第{target_page}页")
- return
- print(f"跳过第{current_page}页,前往第{current_page + 1}页")
- if self.is_high_res:
- self.d.drag(300, 2600, 300, 400, 1)
- else:
- self.d.drag(300, 1400, 300, 400, 1)
- time.sleep(1)
- self.collection_cursor["page_no"] += 1
- self.collection_cursor["item_index"] = 0
- current_page = self.get_current_page_no()
- def move_to_page_range_start(self):
- if not self.page_range:
- return
- start_page = self.page_range["start"]
- current_page = self.get_current_page_no()
- if current_page < start_page:
- self.jump_to_page(start_page)
- def start_collection_app(self):
- self.sort_key = 0
- self.restart_app()
- def open_product_list_page(self):
- last_error = None
- for attempt in range(1, OPEN_PRODUCT_LIST_PAGE_RETRY + 1):
- try:
- self.safe_exec(self.enter_target_page)
- self.reset_collection_cursor()
- if self.sort and self.sort_key == 0:
- self.li_or_lo(self.sort)
- self.move_to_page_range_start()
- return
- except Exception as e:
- last_error = e
- self.loggerMT.warning(
- f"open_product_list_page 第{attempt}/{OPEN_PRODUCT_LIST_PAGE_RETRY}次失败: {e}"
- )
- if attempt >= OPEN_PRODUCT_LIST_PAGE_RETRY:
- break
- ready = self._wait_xpath_exists('//*[@content-desc="看病买药"]', timeout=6, interval=0.5)
- if not ready:
- record_restart_reason(
- reason="open_product_list_page 入口未就绪,执行 restart_app 后重试",
- source="workflow",
- device_id=getattr(self, "device_id", None),
- task_id=getattr(self, "task_id", None),
- step="open_product_list_page",
- action="restart_app",
- fail_count=attempt,
- retry_limit=OPEN_PRODUCT_LIST_PAGE_RETRY,
- search_key=getattr(self, "search_key", None),
- exc=e,
- traceback_text=traceback.format_exc(),
- )
- self.restart_app()
- time.sleep(max(2, self.get_sleep_time()))
- raise RuntimeError(f"open_product_list_page 重试{OPEN_PRODUCT_LIST_PAGE_RETRY}次仍失败: {last_error}")
- def handle_workflow_error(self, step_name):
- action = self.workflow_error_action.get(step_name)
- if action == "back_to_list_page":
- if not self.back_to_list_page():
- raise RuntimeError("退回列表页失败")
- return "collect_single_product"
- if action == "open_product_list_page":
- return "open_product_list_page"
- if action == "start_app":
- return "start_app"
- raise RuntimeError(f"未配置步骤 {step_name} 的错误处理动作")
- def get_list_items(self):
- for _ in range(10):
- items = self.safe_exec(
- self.d.xpath('//androidx.recyclerview.widget.RecyclerView/android.widget.FrameLayout').all
- )
- if items:
- return items
- time.sleep(1)
- raise RuntimeError("列表页商品加载失败")
- def _get_list_visible_params(self):
- """根据分辨率返回列表可见区参数"""
- if self.is_high_res:
- return {"visible_top": 509, "visible_bottom": 2646, "target_top": 519}
- else:
- return {"visible_top": 304, "visible_bottom": 1475, "target_top": 314}
- def _get_sorted_visible_items(self):
- """获取 RecyclerView 中所有可见 item,按 top 从小到大排序"""
- items = self.d.xpath(
- '//androidx.recyclerview.widget.RecyclerView/android.widget.FrameLayout'
- ).all()
- result = []
- for item in (items or []):
- try:
- bounds = item.info.get("bounds") or item.info.get("visibleBounds") or {}
- top = int(bounds.get("top", 0))
- bottom = int(bounds.get("bottom", 0))
- if top >= 0 and bottom > top:
- result.append((top, bottom, item))
- except Exception:
- continue
- result.sort(key=lambda x: x[0])
- return result
- def _anchor_scroll_to_next_page(self):
- """锚点滑动:把最后一个可见 item 滑到可见区顶部,确保翻页不漏商品"""
- sorted_items = self._get_sorted_visible_items()
- if len(sorted_items) < 1:
- return False
- params = self._get_list_visible_params()
- anchor_top = sorted_items[-1][0]
- target_top = params["target_top"]
- scroll_distance = anchor_top - target_top
- if scroll_distance <= 0:
- return False
- screen_width = self.d.info.get("displayWidth", 1220)
- start_x = screen_width // 2
- start_y = params["visible_bottom"] - 200
- end_y = max(start_y - scroll_distance, 100)
- self.d.drag(start_x, start_y, start_x, end_y, duration=1)
- return True
- def move_to_next_list_page(self):
- current_page = self.get_current_page_no()
- # 逐页回告:当前页采集完毕,上报进度
- if self.scheduler and self.task_id:
- resp = self.scheduler.post_report({
- "task_id": self.task_id,
- "platform": str(self.platform),
- "username": self.scheduler.username,
- "current_page": current_page,
- "crawled_count": self.get_collected_data_count(),
- "is_finished": 0,
- })
- if isinstance(resp, dict) and resp.get("code") == "error":
- logging.warning(f"调度器要求停止: {resp.get('msg', '')}")
- return False
- if self.page_range and current_page >= self.page_range["end"]:
- self.wr_re("写", self.device_id, self.sort, current_page)
- print(f'已完成第{current_page}页采集,达到结束页{self.page_range["end"]},停止采集')
- return False
- if self.d.xpath('//*[@text="已经到底啦"]').exists:
- return False
- # 翻页前检查"加载更多"按钮,检测风控卡死
- if self.d.xpath('//*[@text="加载更多"]').exists:
- self._check_wind_control_stuck()
- self.wr_re("写", self.device_id, self.sort, current_page)
- print(f'当前第{current_page}页采集完成,开始滑动到下一页')
- self._anchor_scroll_to_next_page()
- time.sleep(1)
- self.collection_cursor["page_no"] += 1
- self.collection_cursor["item_index"] = 0
- return True
- def _get_list_item_snapshot(self, drug_idx, drug_one):
- """步骤1: 读取列表商品的可见区域和文本快照"""
- bounds = drug_one.info['bounds']
- top = bounds['top']
- bottom = bounds['bottom']
- # print(f'当前商品bottom:{bottom}')
- # print(f'当前商品top:{top}')
- if self.is_high_res:
- if not (509 <= top and bottom <= 2646):
- return None
- else:
- if not (304 < top and bottom <= 1475):
- return None
- item_text_data = self.get_first_texts_by_xpath_groups({
- "product_title": [
- f'//androidx.recyclerview.widget.RecyclerView/android.widget.FrameLayout[{drug_idx}]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.widget.FrameLayout[1]/android.widget.TextView[1]'],
- "price_str": [
- f'//androidx.recyclerview.widget.RecyclerView/android.widget.FrameLayout[{drug_idx}]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.widget.FrameLayout[1]/android.widget.TextView[1]',
- ],
- "shop_name": [
- f'//androidx.recyclerview.widget.RecyclerView/android.widget.FrameLayout[{drug_idx}]//*[contains(@text, "快递电商")]',
- f'//androidx.recyclerview.widget.RecyclerView/android.widget.FrameLayout[{drug_idx}]/android.view.ViewGroup/android.view.ViewGroup/android.view.ViewGroup[2]/android.view.ViewGroup/android.view.ViewGroup[2]/android.view.ViewGroup[2]/android.view.ViewGroup/android.view.ViewGroup/android.widget.FrameLayout/android.widget.TextView',
- f'//androidx.recyclerview.widget.RecyclerView/android.widget.FrameLayout[{drug_idx}]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.widget.FrameLayout[1]/android.widget.TextView[1]',
- f'/hierarchy/android.widget.FrameLayout[2]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[2]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[3]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/androidx.recyclerview.widget.RecyclerView[1]/android.widget.FrameLayout[2]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[1]/android.view.ViewGroup[2]/android.view.ViewGroup[3]/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.widget.FrameLayout[1]/android.widget.TextView[1]'
- ],
- })
- return {
- "drug_idx": drug_idx,
- "drug_one": drug_one,
- "item_text_data": item_text_data,
- }
- def _prepare_list_item_collect(self, item_ctx):
- """步骤2: 解析列表商品信息并完成过滤/去重准备"""
- drug_idx = item_ctx["drug_idx"]
- item_text_data = item_ctx["item_text_data"]
- print(f"这页的第几个商品:{drug_idx}")
- product_title = item_text_data.get("product_title", "")
- if not product_title:
- print("列表当前商品名称不存在")
- self.unrelated_data += 1
- return "continue"
- product_title = product_title[1:] if product_title.startswith('0') else product_title
- print(f"列表当前商品名称:{product_title}")
- if not self.is_link_useful(product_title):
- print(f"is_link_useful 没通过:{product_title}")
- self.unrelated_data += 1
- return "continue"
- self.unrelated_data = 0
- price = ''
- price_str = item_text_data.get("price_str", "")
- print(f"列表当前商品价格:{price_str}")
- if price_str:
- price = float(re.search(r'[\d\.]+', price_str).group())
- shop_name = item_text_data.get("shop_name", "")
- print(f"列表当前商品店铺名称:{shop_name}")
- if price == '' or shop_name == '':
- print("列表当前商品价格或店铺名称不存在","价格:",price,"店铺名:",shop_name)
- return "continue"
- scrape_date = self.get_current_date()
- item_ctx["product_title"] = product_title
- item_ctx["shop_name"] = shop_name
- item_ctx["prefetched_detail"] = {
- "product": product_title,
- "specifications": "",
- "shop": shop_name,
- }
- return "ready"
- def _reset_request_error_counter(self):
- if self.request_error_count:
- print(f"请求错误计数已重置,上一轮累计: {self.request_error_count}")
- self.request_error_count = 0
- def _reset_product_link_missing_counter(self):
- if self.product_link_missing_count:
- print(f"商品链接缺失计数已重置,上一轮累计: {self.product_link_missing_count}")
- self.product_link_missing_count = 0
- def _check_wind_control_stuck(self):
- """
- 风控卡死检测:
- 每隔30秒检查一次 //*[@text="加载更多"] 是否存在,共检查4次。
- 如果4次都存在,判定为风控卡死,抛出异常终止任务。
- 如果任意一次不存在,判定为非风控,继续正常采集。
- """
- self.loggerMT.warning(
- f"开始风控卡死检测,将检查'加载更多'按钮{self.load_more_check_rounds}次,"
- f"间隔{self.load_more_check_interval}秒"
- )
- load_more_xpath = '//*[@text="加载更多"]'
- load_more_exists_count = 0
- for check_round in range(1, self.load_more_check_rounds + 1):
- self.wait_for_ready(getattr(self, "monitor", None))
- try:
- load_more_exists = self.d.xpath(load_more_xpath).exists
- except Exception:
- load_more_exists = False
- if load_more_exists:
- load_more_exists_count += 1
- print(f"第{check_round}/{self.load_more_check_rounds}次检测: '加载更多' 存在 "
- f"(累计{load_more_exists_count}次)")
- self.loggerMT.info(
- f"风控卡死检测 第{check_round}/{self.load_more_check_rounds}次: '加载更多' 存在"
- )
- else:
- print(f"第{check_round}/{self.load_more_check_rounds}次检测: '加载更多' 不存在,"
- f"判定为非风控")
- self.loggerMT.info(
- f"风控卡死检测 第{check_round}/{self.load_more_check_rounds}次: '加载更多' 不存在"
- )
- return
- if check_round < self.load_more_check_rounds:
- print(f"等待{self.load_more_check_interval}秒后进行下一次检测...")
- time.sleep(self.load_more_check_interval)
- # 所有轮次都存在"加载更多",判定为风控卡死
- raise WindControlStuckError(
- f"风控卡死确认:'加载更多'按钮{self.load_more_check_rounds}次检测均存在"
- f"(间隔{self.load_more_check_interval}秒),疑似被风控限制无法获取新数据"
- )
- def _record_product_link_result(self, product_link):
- product_link = str(product_link or "").strip()
- if product_link:
- self._reset_product_link_missing_counter()
- return product_link
- self.product_link_missing_count += 1
- print(
- f"当前商品获取不到商品链接,第{self.product_link_missing_count}/"
- f"{self.product_link_missing_threshold}次"
- )
- if self.product_link_missing_count >= self.product_link_missing_threshold:
- raise ProductLinkUnavailableError(
- f"连续{self.product_link_missing_count}次获取不到商品链接,停止采集"
- )
- return ""
- def _check_request_error_after_click(self, timeout=3, interval=0.5):
- deadline = time.time() + timeout
- while time.time() < deadline:
- self.wait_for_ready(getattr(self, "monitor", None))
- try:
- if self.d.xpath('//*[@text="请求错误"]').exists:
- self.request_error_count += 1
- print(
- f"点击商品后检测到请求错误,第{self.request_error_count}/"
- f"{self.request_error_threshold}次"
- )
- if self.request_error_count >= self.request_error_threshold:
- raise AccountBlockedError(
- f"连续点击{self.request_error_count}个商品均出现请求错误,疑似账号被封禁,停止采集"
- )
- return True
- except AccountBlockedError:
- raise
- except Exception:
- pass
- time.sleep(interval)
- return False
- def _enter_list_item_detail(self, item_ctx):
- """步骤3: 点击商品并交给详情页采集"""
- self.safe_exec(item_ctx["drug_one"].click)
- print('点击目标药品完毕')
- if self._check_request_error_after_click():
- self.back_to_list_page()
- return "continue"
- self._reset_request_error_counter()
- integrate_ok = self.safe_exec(lambda: self.integrate_data(prefetched=item_ctx["prefetched_detail"]))
- print('integrate_data结束')
- if not integrate_ok:
- if self._check_request_error_after_click(timeout=1, interval=0.3):
- self.safe_exec(self.back_to_list_page)
- return "continue"
- self.safe_exec(self.back_to_list_page)
- return "collected"
- def _collect_list_item(self, drug_idx, drug_one):
- item_ctx = self._get_list_item_snapshot(drug_idx, drug_one)
- if not item_ctx:
- return "skip"
- collect_state = self._prepare_list_item_collect(item_ctx)
- if collect_state != "ready":
- return collect_state
- return self._enter_list_item_detail(item_ctx)
- def collect_single_product(self):
- if self.monitor and self.monitor.captcha_appearance_limit_reached:
- raise CollectionStopError(
- f"验证码出现过多,1小时内累计出现{len(self.monitor.captcha_appearance_timestamps)}次验证码,终止任务"
- )
- if self.monitor.verification_count >= self.monitor.MAX_VERIFICATION_RETRY:
- raise RuntimeError("验证码触发过多,暂停程序")
- if self.has_reached_target_count():
- print(f"已达到目标采集数量 {self.count} 条,停止采集")
- return False
- if self.page_range:
- self.move_to_page_range_start()
- current_page = self.get_current_page_no()
- if current_page > self.page_range["end"]:
- print(f"当前已在第{current_page}页,超过结束页{self.page_range['end']},停止采集")
- return False
- items = self.get_list_items()
- print(f'当前第{self.get_current_page_no()}页,共有{len(items)}个商品')
- while self.collection_cursor["item_index"] < len(items):
- item_index = self.collection_cursor["item_index"]
- self.collection_cursor["item_index"] += 1
- result = self._collect_list_item(item_index + 1, items[item_index])
- if result == "collected":
- if self.count is not None:
- self._wait_post_process_tasks()
- if self.has_reached_target_count():
- print(f"已达到目标采集数量 {self.count} 条,停止采集")
- return False
- return True
- if result == "continue":
- return True
- if not self.move_to_next_list_page():
- print('已经到达列表页最底部')
- return False
- return True
- def execute_workflow_step(self, step_name):
- if step_name == "start_app":
- self.safe_exec(self.start_collection_app)
- return "open_product_list_page"
- if step_name == "open_product_list_page":
- self.safe_exec(self.open_product_list_page)
- MT.open_page_fail_count = 0 # 成功则重置封号计数
- return "collect_single_product"
- if step_name == "collect_single_product":
- has_next = self.safe_exec(self.collect_single_product)
- if not has_next:
- return None
- print('目前连续无关数据量: ', self.unrelated_data)
- if self.unrelated_data > self.max_unrelated_data:
- print(f"连续超过{self.max_unrelated_data}个不达标的数据则停止采集")
- self.finish_task_normally(
- self.get_current_page_no(),
- f"连续超过{self.max_unrelated_data}个不达标的数据则停止采集",
- )
- return None
- return "collect_single_product"
- raise RuntimeError(f"未知流程步骤: {step_name}")
- def main(self, device_id):
- self.device_id = device_id
- self.connect_devices(device_id)
- time.sleep(self.get_sleep_time())
- self.monitor = SpiderMonitor(self)
- self.monitor.start()
- current_step = "start_app"
- step_failures = {step: 0 for step in self.workflow_retry_limit}
- try:
- while current_step:
- try:
- next_step = self.execute_workflow_step(current_step)
- step_failures[current_step] = 0
- current_step = next_step
- except CollectionStopError:
- raise
- except Exception as e:
- # 验证码重试超限,直接终止任务并回告
- if "验证码触发过多" in str(e):
- self.finish_task_abnormally(self.get_current_page_no(), f"验证码处理失败: {e}")
- raise CollectionStopError(str(e))
- print(f'{current_step} 执行异常: {e}')
- time.sleep(3)
- step_failures[current_step] += 1
- # 封号检测:open_product_list_page 连续失败
- if current_step == "open_product_list_page":
- MT.open_page_fail_count += 1
- print(f"⚠️ open_product_list_page 连续失败 {MT.open_page_fail_count}/{MT.open_page_fail_threshold} 次")
- if MT.open_page_fail_count >= MT.open_page_fail_threshold:
- msg = f"疑似账号被封禁:open_product_list_page 连续失败 {self.open_page_fail_threshold} 次"
- logging.error(msg)
- self.finish_task_abnormally(self.get_current_page_no(), msg)
- raise CollectionStopError(msg)
- retry_limit = self.workflow_retry_limit.get(current_step)
- next_action = self.workflow_error_action.get(current_step)
- record_restart_reason(
- reason="工作流步骤异常,准备按配置重新开始",
- source="workflow",
- device_id=self.device_id,
- task_id=self.task_id,
- step=current_step,
- action=next_action,
- fail_count=step_failures[current_step],
- retry_limit=retry_limit,
- search_key=self.search_key,
- exc=e,
- traceback_text=traceback.format_exc(),
- )
- if step_failures[current_step] > self.workflow_retry_limit[current_step]:
- raise
- current_step = self.handle_workflow_error(current_step)
- return self.finish_task_normally(self.get_current_page_no(), "美团任务执行完成")
- finally:
- self._wait_post_process_tasks()
- self._shutdown_post_process_executor()
- self.monitor.stop()
- self.monitor.join()
- def fetch_task_from_scheduler(scheduler, device_id):
- """从已有的调度器获取一个任务,转换为 device_list 兼容的格式。没有任务返回 None。"""
- task = scheduler.get_task()
- if not task:
- return None
- start_offset = task.get("current_page", 0) # 移动端起始偏移量,0=从头开始
- # start_offset = 0 # 移动端起始偏移量,0=从头开始
- start_page = start_offset if start_offset > 0 else 1
- end_page = task.get("end_page", 0)
- # 转成 page_range 格式,MT.open_product_list_page → move_to_page_range_start 会跳页
- if start_page > 1 or end_page > 0:
- page_range = {"start": start_page, "end": end_page if end_page > 0 else 200}
- else:
- page_range = []
- return {
- "search_key": f"{task.get('product_brand', '')} {task.get('product_name', '')}".strip(),
- "title_key": task.get("product_name", ""),
- "spec_list": parse_spec_list(task.get("product_specs")),
- "brand": task.get("product_brand", ""),
- "sort": "",
- "collect_range": [],
- "page_range": page_range,
- "platform": PLATFORM_MT,
- "task_id": task.get("id"),
- "enterprise_id": task.get("company_id"),
- "equipment_id": task.get("collect_equipment_id", 0),
- "device_name": device_id,
- "collect_equipment_account_id": 0,
- "collect_region_id": 0,
- "collect_round": task.get("collect_round", 1),
- "sampling_cycle": "",
- "sampling_start_time": "",
- "sampling_end_time": "",
- "count": 150,
- "search_task_mode": SEARCH_TASK_MODE,
- "workflow_retry_limit": {
- "start_app": 3,
- "open_product_list_page": 3,
- "collect_single_product": 3,
- },
- "workflow_error_action": {
- "start_app": "start_app",
- "open_product_list_page": "start_app",
- "collect_single_product": "back_to_list_page",
- },
- }
- def run_device(device_id, scheduler=None):
- """单个设备的采集任务。scheduler 由外部传入复用,避免重复启停心跳。"""
- if device_id not in device_list:
- logging.error(f"设备id没有配置: {device_id}")
- return
- own_scheduler = False
- if scheduler is None:
- scheduler = CrawlerScheduler(DEVICE_ID=device_id, platform=str(PLATFORM_MT))
- scheduler.start()
- time.sleep(2)
- own_scheduler = True
- logging.info(f"[设备 {device_id}] 调度器已启动(心跳+回告)")
- tasks = device_list[device_id]
- logging.info(f"[设备 {device_id}] 开始执行,共 {len(tasks)} 个任务")
- for task in tasks:
- mode = task.get("search_task_mode", SEARCH_TASK_MODE)
- variants = build_search_variants(task.get("search_key"), task.get("spec_list"), mode)
- logging.info(
- f"[设备 {device_id}] 任务 {task.get('search_key')} 使用搜索模式 {mode},共 {len(variants)} 组搜索词")
- for variant_idx, variant in enumerate(variants, start=1):
- cycle_no = 0
- while True:
- cycle_no += 1
- mt = None
- variant_search_key = variant["search_key"]
- variant_specs = variant["spec_list"]
- logging.info(
- f'[设备 {device_id}] ========== 搜索组 {variant_idx}/{len(variants)} '
- f'{variant_search_key} 第 {cycle_no} 轮采集开始 =========='
- )
- counter_key = _build_failure_counter_key(
- "local",
- device_id,
- search_key=variant_search_key,
- )
- try:
- mt = MT(
- variant_search_key,
- task["title_key"],
- variant_specs,
- task["brand"],
- task.get("sort"),
- task.get("collect_range"),
- task.get("page_range"),
- task.get("workflow_retry_limit"),
- task.get("workflow_error_action"),
- platform=task.get("platform"),
- task_id=task.get("task_id"),
- enterprise_id=task.get("enterprise_id"),
- sampling_cycle=task.get("sampling_cycle"),
- sampling_start_time=task.get("sampling_start_time"),
- sampling_end_time=task.get("sampling_end_time"),
- count=task.get("count"),
- collect_equipment_id=task.get("collect_equipment_id"),
- device_name=task.get("device_name"),
- collect_equipment_account_id=task.get("collect_equipment_account_id"),
- collect_region_id=task.get("collect_region_id"),
- collect_round=task.get("collect_round"),
- scheduler=scheduler,
- )
- mt.main(device_id)
- logging.info(f'[设备 {device_id}] 搜索组 {variant_idx}/{len(variants)} 本轮采集完成')
- reset_failure_notice_counter(counter_key)
- reset_captcha_restart_count(counter_key)
- break # 成功则跳出当前搜索组重试循环
- except CollectionStopError as e:
- tb_text = traceback.format_exc()
- logging.exception(
- f'[设备 {device_id}] 搜索组 {variant_idx}/{len(variants)} 关键字 {variant_search_key} '
- f'检测到致命停止条件:{e}'
- )
- record_restart_reason(
- reason="run_device 检测到致命停止条件,终止程序",
- source="collection_stop_local",
- device_id=device_id,
- task_id=task.get("task_id"),
- search_key=variant_search_key,
- cycle_no=cycle_no,
- exc=e,
- traceback_text=tb_text,
- )
- if mt is not None:
- end_page = mt.get_current_page_no() if hasattr(mt, "get_current_page_no") else getattr(mt,
- "page",
- 0)
- mt.finish_task_abnormally(end_page, f"任务终止: {e}")
- raise
- except Exception as e:
- tb_text = traceback.format_exc()
- logging.exception(
- f'[设备 {device_id}] 搜索组 {variant_idx}/{len(variants)} 关键字 {variant_search_key} 采集异常:{e}')
- record_restart_reason(
- reason="run_device 捕获异常后进入下一轮重试",
- source="run_device",
- device_id=device_id,
- task_id=task.get("task_id"),
- search_key=variant_search_key,
- cycle_no=cycle_no,
- exc=e,
- traceback_text=tb_text,
- )
- if _is_captcha_related_error(str(e), tb_text):
- captcha_restart_count = increase_captcha_restart_count(counter_key)
- record_restart_reason(
- reason=f"验证码导致重启,1小时窗口内累计第{captcha_restart_count}次",
- source="captcha_restart_alert_local",
- device_id=device_id,
- task_id=task.get("task_id"),
- search_key=variant_search_key,
- fail_count=captcha_restart_count,
- retry_limit=CAPTCHA_STOP_THRESHOLD,
- cycle_no=cycle_no,
- exc=e,
- traceback_text=tb_text,
- )
- # 1小时内验证码导致重启超过阈值 → 停止任务并回告
- if captcha_restart_count >= CAPTCHA_STOP_THRESHOLD:
- logging.error(
- "1小时内验证码导致重启 %s 次,达到阈值 %s,终止当前任务并回告",
- captcha_restart_count, CAPTCHA_STOP_THRESHOLD
- )
- if mt is not None:
- end_page = mt.get_current_page_no() if hasattr(mt, "get_current_page_no") else getattr(mt, "page", 0)
- mt.finish_task_abnormally(
- end_page,
- f"1小时内验证码过多,累计重启{captcha_restart_count}次"
- )
- raise CollectionStopError(
- f"1小时内验证码过多,累计重启{captcha_restart_count}次,终止任务"
- )
- else:
- reset_captcha_restart_count(counter_key)
- _, fail_count, _ = should_send_failure_notice(counter_key, str(e))
- logging.warning(
- "[设备 %s] 搜索组 %s/%s 瞬时入口异常,第%s/%s次",
- device_id, variant_idx, len(variants), fail_count, FAILURE_NOTICE_THRESHOLD
- )
- # 发生异常后继续循环重试,超过上限则回告并停止
- if cycle_no >= MAX_RUN_DEVICE_RETRIES:
- logging.error(
- f'[设备 {device_id}] 搜索组 {variant_idx}/{len(variants)} '
- f'重试{cycle_no}次仍失败,停止: {e}'
- )
- if mt is not None:
- end_page = mt.get_current_page_no() if hasattr(mt, "get_current_page_no") else getattr(mt, "page", 0)
- mt.finish_task_abnormally(end_page, f"重试{cycle_no}次仍失败: {e}")
- break
- finally:
- if mt and hasattr(mt, 'close'):
- mt.close()
- logging.info(f"[设备 {device_id}] 所有任务执行完毕")
- if own_scheduler and scheduler is not None:
- scheduler.stop()
- class DeviceIdLogFilter(logging.Filter):
- """给每条日志注入当前设备ID(运行时读取全局 DEVICE_ID,终端传参后自动生效)"""
- def filter(self, record):
- record.device_id = DEVICE_ID
- return True
- def main():
- logging.basicConfig(
- level=logging.INFO,
- format='%(asctime)s [%(threadName)s] [%(device_id)s] %(levelname)s: %(message)s'
- )
- # 给所有 root handler 挂上设备ID过滤器
- _device_filter = DeviceIdLogFilter()
- for _h in logging.getLogger().handlers:
- _h.addFilter(_device_filter)
- # 终端传入设备 ID:python main.py T4VK4LM7AAUOV8AY
- global DEVICE_ID
- if len(sys.argv) > 1 and sys.argv[1].strip():
- DEVICE_ID = sys.argv[1].strip()
- logging.info(f"使用终端传入设备: {DEVICE_ID}")
- # 同步设备ID到 commons.Logger(scheduler 等模块的日志带上设备ID)
- try:
- from commons.Logger import set_device_id
- set_device_id(DEVICE_ID)
- except Exception:
- pass
- # 自动模式:全局只创建一个调度器 + 一个心跳线程,所有任务复用
- scheduler = None
- if not MANUAL_MODE:
- scheduler = CrawlerScheduler(DEVICE_ID=DEVICE_ID, platform=str(PLATFORM_MT))
- scheduler.start()
- time.sleep(2)
- logging.info(f"[{DEVICE_ID}] 调度器已启动(全局心跳线程),开始获取任务...")
- try:
- while True:
- try:
- if MANUAL_MODE:
- if DEVICE_ID not in device_list:
- logging.error(f"设备id没有配置在 device_list 中: {DEVICE_ID}")
- return
- run_device(DEVICE_ID)
- else:
- task = None
- try:
- task = fetch_task_from_scheduler(scheduler, DEVICE_ID)
- except Exception as e:
- logging.exception(f"获取任务失败: {e},{LOOP_INTERVAL_SECONDS}秒后重试...")
- time.sleep(LOOP_INTERVAL_SECONDS)
- continue
- if task is None:
- logging.info(f"当前没有可执行的任务,{LOOP_INTERVAL_SECONDS}秒后重试...")
- time.sleep(LOOP_INTERVAL_SECONDS)
- continue
- logging.info(f"获取到任务: {task.get('search_key')} (task_id={task.get('task_id')})")
- device_list[DEVICE_ID] = [task]
- run_device(DEVICE_ID, scheduler=scheduler)
- logging.info(f"本轮任务完成,等待 {LOOP_INTERVAL_SECONDS} 秒后开始下一轮...")
- except CollectionStopError as e:
- logging.exception(f"检测到致命停止条件,本轮终止: {e}")
- except Exception as e:
- logging.exception(f"本轮任务异常: {e}")
- time.sleep(LOOP_INTERVAL_SECONDS)
- finally:
- if scheduler is not None:
- scheduler.stop()
- logging.info("调度器已停止")
- device_list = {
- "T4VK4LM7AAUOV8AY": [
- {
- "search_key": "金活 依马打正红花油",
- "title_key": "依马打正红花油",
- "spec_list": [''],
- "brand": "金活",
- "sort": "",
- "collect_range": [],
- "page_range": [],
- "platform": 4,
- "task_id": "",
- "enterprise_id": 5,
- "equipment_id": 39,
- "device_name": None,
- "collect_equipment_account_id": 13,
- "collect_region_id": 0,
- "collect_round": 1,
- "sampling_cycle": '1,4',
- "sampling_start_time": 1778774400,
- "sampling_end_time": 1793116799,
- "search_task_mode": "name_with_each_spec",
- "workflow_retry_limit": {
- "start_app": 3,
- "open_product_list_page": 3,
- "collect_single_product": 3,
- },
- "workflow_error_action": {
- "start_app": "start_app",
- "open_product_list_page": "start_app",
- "collect_single_product": "back_to_list_page",
- }
- },
- ],
- }
- if __name__ == '__main__':
- main()
|