| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978 |
- 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 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=4, 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:
- 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
- # 风控卡死检测:"加载更多"按钮检测
- 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)
- if normalized_title_key != "" and normalized_title_key not in normalized_title:
- print(f"当前商品名称:{product_title} 不包含{self.title_key}关键字")
- return False
- if normalized_brand != "" and normalized_brand not in normalized_title:
- 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("列表当前商品价格或店铺名称不存在")
- 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)
- 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
- 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()
- def main():
- logging.basicConfig(
- level=logging.INFO,
- format='%(asctime)s [%(threadName)s] %(levelname)s: %(message)s'
- )
- # 自动模式:全局只创建一个调度器 + 一个心跳线程,所有任务复用
- 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()
|