main.py 174 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978
  1. from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
  2. import requests
  3. import base64
  4. import cv2
  5. import uiautomator2 as u2
  6. import time
  7. import subprocess
  8. import re
  9. import random
  10. import datetime
  11. import json
  12. import unicodedata
  13. from aip import AipOcr
  14. import threading
  15. from collections import deque
  16. import numpy as np
  17. import secrets
  18. import os
  19. import oss2
  20. import urllib.parse
  21. from config import Config
  22. from logger import setup_logger
  23. import logging
  24. from PIL import Image
  25. import http.client
  26. import traceback
  27. from pathlib import Path
  28. from db import get_mysql
  29. from scheduler import CrawlerScheduler
  30. from decimal import Decimal
  31. from area import AreaService
  32. try:
  33. from yzm import yzm as solve_captcha
  34. except Exception:
  35. solve_captcha = None
  36. setup_logger("mt_spider") # 初始化日志
  37. def get_access_token():
  38. AppKey = "tRK2RhyItCSh6BzyT4CNVXQa"
  39. AppSrcret = "TDgKiPo94i2mOM1sDqOuDnlcK1bG66jh"
  40. token_url = 'https://aip.baidubce.com/oauth/2.0/token'
  41. url = f"{token_url}?grant_type=client_credentials&client_id={AppKey}&client_secret={AppSrcret}"
  42. payload = ""
  43. headers = {
  44. 'Content-Type': 'application/json',
  45. 'Accept': 'application/json'
  46. }
  47. response = requests.request("POST", url, headers=headers, data=payload)
  48. try:
  49. return response.json()['access_token']
  50. except:
  51. return None
  52. LOOP_INTERVAL_SECONDS = 600 # 每轮任务之间的等待间隔(秒),默认10分钟
  53. DEVICE_ID = "T4VK4LM7AAUOV8AY" # 指定要连接的设备
  54. PLATFORM_MT = 4
  55. # True: 跑 device_list 里手动配置的任务;False: 从外部调度器获取任务
  56. MANUAL_MODE = False
  57. SEARCH_TASK_MODE = "name_with_each_spec"
  58. MAX_RUN_DEVICE_RETRIES = 3 # run_device 外层重试上限,超过后回告并停止
  59. OPEN_PRODUCT_LIST_PAGE_RETRY = 3
  60. FAILURE_NOTICE_THRESHOLD = 3
  61. REQUEST_ERROR_STOP_THRESHOLD = 5
  62. PRODUCT_LINK_STOP_THRESHOLD = 5
  63. # 是否记录"因为何种问题重新开始"的日志
  64. ENABLE_RESTART_REASON_LOG = True
  65. # 重新开始原因日志文件
  66. RESTART_REASON_LOG_FILE = "./restart_reason_logs/a_mt_restart_reason.log"
  67. # 是否启用"验证码连续重启失败"告警(飞书)
  68. ENABLE_CAPTCHA_RESTART_ALERT = False
  69. # 店铺补齐调试日志开关
  70. ENABLE_SHOP_DEBUG = False
  71. # 验证码连续重启失败告警阈值
  72. CAPTCHA_RESTART_ALERT_THRESHOLD = 3
  73. # 验证码连续重启计数持久化文件
  74. CAPTCHA_RESTART_COUNTER_FILE = "./restart_reason_logs/captcha_restart_counter.json"
  75. # 1小时内验证码导致重启超过此值则直接停止任务并回告
  76. CAPTCHA_STOP_THRESHOLD = 5
  77. CAPTCHA_STOP_WINDOW_SECONDS = 3600
  78. failure_notice_counters = {}
  79. failure_notice_lock = threading.Lock()
  80. captcha_restart_lock = threading.Lock()
  81. captcha_restart_counts_cache = None
  82. oss_bucket_cache_lock = threading.Lock()
  83. oss_bucket_cache = {}
  84. def decode_qr(image_path):
  85. for i in range(3):
  86. img = cv2.imread(image_path)
  87. # 放大2~4倍(关键)
  88. if (i == 0):
  89. img2 = img[1200:, :300]
  90. img3 = img[1500:2000,50:500]
  91. img = cv2.resize(img, None, fx=3, fy=3, interpolation=cv2.INTER_CUBIC)
  92. img2 = cv2.resize(img2, None, fx=3, fy=3, interpolation=cv2.INTER_CUBIC)
  93. else:
  94. img3 = img[1500:2000,50:500]
  95. img2 = img[1000:, :200]
  96. img = cv2.resize(img, None, fx=(3 - i), fy=(3 - i), interpolation=cv2.INTER_CUBIC)
  97. img2 = cv2.resize(img2, None, fx=(3 - i), fy=(3 - i), interpolation=cv2.INTER_CUBIC)
  98. detector = cv2.wechat_qrcode_WeChatQRCode()
  99. data, points = detector.detectAndDecode(img2)
  100. if data != ():
  101. return data
  102. data, points = detector.detectAndDecode(img3)
  103. if data != ():
  104. return data
  105. data, points = detector.detectAndDecode(img)
  106. if data != ():
  107. return data
  108. if (data == ()):
  109. data = ''
  110. return data
  111. class CollectionStopError(RuntimeError):
  112. """需要立即停止当前采集流程的致命异常。"""
  113. class AccountBlockedError(CollectionStopError):
  114. """疑似账号被封禁,需立即停止当前采集流程。"""
  115. class ProductLinkUnavailableError(CollectionStopError):
  116. """连续获取不到商品链接,需立即停止当前采集流程。"""
  117. class WindControlStuckError(CollectionStopError):
  118. """检测到风控卡死:连续重复商品 + "加载更多"按钮持续存在,疑似被风控限制。"""
  119. def is_high_resolution_device(d):
  120. """检测是否为高分辨率设备 (1220x2712)。
  121. 通过屏幕高度判断:>2000 为高分辨率,否则为低分辨率 (720x1640)。
  122. """
  123. try:
  124. h = d.info.get('displayHeight', 0)
  125. return h > 2000
  126. except Exception:
  127. return False
  128. def parse_optional_int(value, default=None):
  129. if value in (None, ""):
  130. return default
  131. try:
  132. return int(value)
  133. except (TypeError, ValueError):
  134. return default
  135. def parse_spec_list(value):
  136. if value is None:
  137. return []
  138. if isinstance(value, (list, tuple)):
  139. return [str(item).strip() for item in value if str(item).strip()]
  140. text = str(value).strip()
  141. if not text:
  142. return []
  143. parts = re.split(r"[,,/\s]+", text)
  144. return [part.strip() for part in parts if part.strip()]
  145. def normalize_match_text(value):
  146. text = "" if value is None else str(value)
  147. # 统一全角/半角,移除各种空白和零宽字符,避免"看起来一样但匹配失败"
  148. text = unicodedata.normalize("NFKC", text)
  149. text = re.sub(r"[\s\u00A0\u200B-\u200D\uFEFF]+", "", text)
  150. return text
  151. def build_search_variants(search_key, spec_list, mode=SEARCH_TASK_MODE):
  152. base_search_key = str(search_key or "").strip()
  153. cleaned_specs = [str(spec).strip() for spec in (spec_list or []) if str(spec).strip()]
  154. if mode == "name_with_each_spec" and cleaned_specs:
  155. variants = []
  156. seen = set()
  157. for spec in cleaned_specs:
  158. query = f"{base_search_key}{spec}".strip() if base_search_key else spec
  159. if query and query not in seen:
  160. variants.append({
  161. "search_key": query,
  162. "spec_list": [spec],
  163. })
  164. seen.add(query)
  165. if variants:
  166. return variants
  167. return [{
  168. "search_key": base_search_key,
  169. "spec_list": cleaned_specs,
  170. }]
  171. def _build_failure_counter_key(source, device_id, task_id=None, search_key=None):
  172. return f"{source}:{device_id}:{task_id or ''}:{search_key or ''}"
  173. def _is_transient_open_page_error(err_msg):
  174. text = str(err_msg or "")
  175. keywords = (
  176. "open_product_list_page",
  177. "点击首页搜索入口失败",
  178. "进入看病买药页失败",
  179. "vf_search_carousel_text",
  180. "看病买药",
  181. )
  182. return any(k in text for k in keywords)
  183. def should_send_failure_notice(counter_key, err_msg, threshold=FAILURE_NOTICE_THRESHOLD):
  184. is_transient = _is_transient_open_page_error(err_msg)
  185. with failure_notice_lock:
  186. if is_transient:
  187. count = failure_notice_counters.get(counter_key, 0) + 1
  188. failure_notice_counters[counter_key] = count
  189. return count >= threshold, count, True
  190. failure_notice_counters[counter_key] = 0
  191. return True, 1, False
  192. def reset_failure_notice_counter(counter_key):
  193. with failure_notice_lock:
  194. failure_notice_counters[counter_key] = 0
  195. def _is_captcha_related_error(err_msg, traceback_text=None):
  196. text = f"{err_msg or ''}\n{traceback_text or ''}"
  197. lower_text = text.lower()
  198. keywords_cn = ("验证码", "滑块", "拼图", "安全验证", "人机验证", "请点击")
  199. keywords_en = ("captcha", "slider", "puzzle", "verify", "verification", "yoda")
  200. return any(k in text for k in keywords_cn) or any(k in lower_text for k in keywords_en)
  201. def _load_captcha_restart_counts():
  202. global captcha_restart_counts_cache
  203. if captcha_restart_counts_cache is not None:
  204. return captcha_restart_counts_cache
  205. data = {}
  206. try:
  207. if os.path.exists(CAPTCHA_RESTART_COUNTER_FILE):
  208. with open(CAPTCHA_RESTART_COUNTER_FILE, "r", encoding="utf-8") as f:
  209. raw = json.load(f)
  210. if isinstance(raw, dict):
  211. for k, v in raw.items():
  212. try:
  213. key = str(k)
  214. if isinstance(v, list):
  215. # 新格式:时间戳列表,只保留字符串
  216. data[key] = [str(ts) for ts in v if isinstance(ts, str)]
  217. elif isinstance(v, (int, float)):
  218. # 兼容旧格式:单个整数 → 转为空列表(旧数据清零)
  219. data[key] = []
  220. else:
  221. data[key] = []
  222. except Exception:
  223. continue
  224. except Exception as e:
  225. logging.exception(f"读取验证码重启计数失败: {e}")
  226. captcha_restart_counts_cache = data
  227. return captcha_restart_counts_cache
  228. def _save_captcha_restart_counts(data):
  229. try:
  230. log_dir = os.path.dirname(CAPTCHA_RESTART_COUNTER_FILE)
  231. if log_dir:
  232. os.makedirs(log_dir, exist_ok=True)
  233. with open(CAPTCHA_RESTART_COUNTER_FILE, "w", encoding="utf-8") as f:
  234. json.dump(data, f, ensure_ascii=False, indent=2)
  235. except Exception as e:
  236. logging.exception(f"写入验证码重启计数失败: {e}")
  237. def _prune_expired_timestamps(timestamps, window_seconds):
  238. """剔除超过时间窗口的旧时间戳"""
  239. now = time.time()
  240. cutoff = now - window_seconds
  241. return [ts for ts in timestamps if _timestamp_to_epoch(ts) > cutoff]
  242. def _timestamp_to_epoch(ts):
  243. """将 ISO 时间字符串转为 epoch 秒,解析失败返回 0"""
  244. try:
  245. return time.mktime(time.strptime(str(ts), "%Y-%m-%dT%H:%M:%S"))
  246. except Exception:
  247. return 0
  248. def increase_captcha_restart_count(counter_key):
  249. """记录一次验证码重启,返回当前时间窗口内的累计次数"""
  250. with captcha_restart_lock:
  251. data = _load_captcha_restart_counts()
  252. timestamps = data.get(counter_key, [])
  253. if not isinstance(timestamps, list):
  254. timestamps = []
  255. # 追加当前时间
  256. now_str = time.strftime("%Y-%m-%dT%H:%M:%S")
  257. timestamps.append(now_str)
  258. # 剔除超过 1 小时的旧记录
  259. timestamps = _prune_expired_timestamps(timestamps, CAPTCHA_STOP_WINDOW_SECONDS)
  260. data[counter_key] = timestamps
  261. _save_captcha_restart_counts(data)
  262. return len(timestamps)
  263. def get_captcha_restart_count_in_window(counter_key, window_seconds=None):
  264. """查询时间窗口内的验证码重启次数(不追加新记录)"""
  265. if window_seconds is None:
  266. window_seconds = CAPTCHA_STOP_WINDOW_SECONDS
  267. with captcha_restart_lock:
  268. data = _load_captcha_restart_counts()
  269. timestamps = data.get(counter_key, [])
  270. if not isinstance(timestamps, list):
  271. return 0
  272. timestamps = _prune_expired_timestamps(timestamps, window_seconds)
  273. return len(timestamps)
  274. def reset_captcha_restart_count(counter_key):
  275. with captcha_restart_lock:
  276. data = _load_captcha_restart_counts()
  277. timestamps = data.get(counter_key)
  278. if timestamps:
  279. data[counter_key] = []
  280. _save_captcha_restart_counts(data)
  281. def should_send_captcha_restart_alert(restart_count):
  282. if not ENABLE_CAPTCHA_RESTART_ALERT:
  283. return False
  284. if CAPTCHA_RESTART_ALERT_THRESHOLD <= 0:
  285. return False
  286. if restart_count < CAPTCHA_RESTART_ALERT_THRESHOLD:
  287. return False
  288. return restart_count % CAPTCHA_RESTART_ALERT_THRESHOLD == 0
  289. def record_restart_reason(
  290. reason,
  291. device_id=None,
  292. task_id=None,
  293. step=None,
  294. action=None,
  295. fail_count=None,
  296. retry_limit=None,
  297. cycle_no=None,
  298. search_key=None,
  299. source=None,
  300. exc=None,
  301. traceback_text=None,
  302. ):
  303. if not ENABLE_RESTART_REASON_LOG:
  304. return
  305. payload = {
  306. "time": time.strftime("%Y-%m-%d %H:%M:%S"),
  307. "source": source,
  308. "reason": str(reason),
  309. "device_id": device_id,
  310. "task_id": task_id,
  311. "step": step,
  312. "action": action,
  313. "fail_count": fail_count,
  314. "retry_limit": retry_limit,
  315. "cycle_no": cycle_no,
  316. "search_key": search_key,
  317. "error": str(exc) if exc is not None else None,
  318. "traceback": traceback_text,
  319. }
  320. try:
  321. log_dir = os.path.dirname(RESTART_REASON_LOG_FILE)
  322. if log_dir:
  323. os.makedirs(log_dir, exist_ok=True)
  324. with open(RESTART_REASON_LOG_FILE, "a", encoding="utf-8") as f:
  325. f.write(json.dumps(payload, ensure_ascii=False) + "\n")
  326. except Exception as log_err:
  327. logging.exception(f"写入重启原因日志失败: {log_err}")
  328. class SpiderMonitor(threading.Thread):
  329. """全局弹窗监控线程(增强版)"""
  330. def __init__(self, spider_instance):
  331. super().__init__(daemon=True)
  332. self.spider = spider_instance
  333. self.running = True
  334. self.pausing = threading.Event() # 主线程同步事件
  335. self.last_verification_time = 0
  336. self.verification_count = 0
  337. # 验证码重试次数
  338. self.MAX_VERIFICATION_RETRY = 6
  339. self.recent_clicks = deque(maxlen=10) # 防重复点击
  340. self.logger = logging.getLogger("SpiderMonitor")
  341. self.last_verification_probe_log_ts = 0
  342. self.last_reconnect_ts = 0
  343. # 验证码出现频率统计(1小时滑动窗口)
  344. self.captcha_appearance_timestamps = [] # 验证码出现时间戳列表
  345. self.MAX_CAPTCHA_APPEARANCES = 5 # 1小时内最多出现次数
  346. self.CAPTCHA_APPEARANCE_WINDOW = 3600 # 时间窗口(秒)
  347. self.captcha_appearance_limit_reached = False # 是否已达到验证码出现上限
  348. # 可配置化弹窗规则
  349. self.popup_rules = {
  350. "simple": [
  351. ('//*[@text="确定"]', "点击确定"),
  352. ('//*[@text="允许"]', "点击允许"),
  353. ('//*[@text="关闭"]', "点击关闭"),
  354. ('//*[@resource-id="com.sankuai.meituan:id/close"]', "关闭按钮"),
  355. ('//*[@resource-id="com.sankuai.meituan:id/address_center_location_close"]', "关闭按钮"),
  356. ('//*[@resource-id="com.sankuai.meituan:id/location_close"]', "关闭按钮"),
  357. ('//*[@resource-id="com.sankuai.meituan:id/btn_close"]', "关闭按钮"),
  358. ],
  359. "verification": [
  360. '//*[contains(@text, "依次点击")]',
  361. '//*[contains(@text, "拖动滑块刚")]', # 这个需要拖动滑块至最右边,然后再截图
  362. '//*[contains(@text, "请输入图片中的内容")]',
  363. '//*[contains(@text, "用最短线连接")]',
  364. '//*[contains(@text, "请按语序依次点击")]',
  365. '//*[contains(@text, "请向右滑动滑块")]',
  366. '//*[contains(@text, "请拖动下方滑块完成拼图")]',
  367. '//*[contains(@text, "请点击") and contains(@text, "图")]',
  368. '//*[contains(@text, "验证码")]',
  369. '//*[contains(@text, "安全验证")]',
  370. '//*[contains(@text, "完成拼图")]',
  371. '//*[contains(@text, "滑块")]',
  372. '//*[contains(@resource-id, "captcha")]',
  373. '//*[contains(@resource-id, "yoda")]',
  374. '//*[contains(@resource-id, "puzzle")]',
  375. '//*[contains(@resource-id, "slider")]',
  376. '//*[contains(@resource-id, "verify")]',
  377. ]
  378. }
  379. def _device_tag(self):
  380. device_id = getattr(self.spider, "device_id", None) or "unknown"
  381. device_name = getattr(self.spider, "device_name", None) or "unknown"
  382. equipment_id = getattr(self.spider, "equipment_id", None)
  383. if equipment_id not in (None, ""):
  384. return f"[device_id={device_id} device_name={device_name} equipment_id={equipment_id}]"
  385. return f"[device_id={device_id} device_name={device_name}]"
  386. def run(self):
  387. while self.running:
  388. try:
  389. handled = self.check_and_handle_popup()
  390. time.sleep(2 if handled else 1)
  391. except http.client.RemoteDisconnected as e:
  392. self.logger.exception("%s 监控线程连接断开: %s", self._device_tag(), e)
  393. now = time.time()
  394. # 监控线程受控重连:最小间隔内仅触发一次,避免抖动重连
  395. if now - self.last_reconnect_ts >= 8:
  396. self.last_reconnect_ts = now
  397. try:
  398. if hasattr(self.spider, "reconnect_device"):
  399. if self.spider.reconnect_device():
  400. self.logger.info("%s 监控线程触发重连成功", self._device_tag())
  401. except Exception:
  402. self.logger.exception("%s 监控线程触发重连失败", self._device_tag())
  403. time.sleep(2)
  404. except Exception as e:
  405. self.logger.exception("%s 监控线程异常: %s", self._device_tag(), e)
  406. time.sleep(1)
  407. def _is_recent_click(self, xpath):
  408. """防止重复点击同一个弹窗"""
  409. key = f"{xpath}_{int(time.time())}"
  410. if key in self.recent_clicks:
  411. return True
  412. self.recent_clicks.append(key)
  413. return False
  414. def _try_auto_solve_verification(self, d):
  415. if solve_captcha is None:
  416. return False
  417. try:
  418. device_id = getattr(self.spider, "device_id", None)
  419. try:
  420. solve_captcha(d, device_id=device_id)
  421. except TypeError:
  422. solve_captcha(d)
  423. return True
  424. except Exception as e:
  425. self.logger.exception("%s auto captcha solve failed: %s", self._device_tag(), e)
  426. return False
  427. def _get_xpath_bounds(self, d, xpath):
  428. try:
  429. node = d.xpath(xpath)
  430. if not node.exists:
  431. return None
  432. info = node.info or {}
  433. bounds = info.get("visibleBounds") or info.get("bounds") or {}
  434. if not bounds:
  435. return None
  436. left = int(bounds.get("left", 0))
  437. right = int(bounds.get("right", 0))
  438. top = int(bounds.get("top", 0))
  439. bottom = int(bounds.get("bottom", 0))
  440. width = right - left
  441. height = bottom - top
  442. if width <= 0 or height <= 0:
  443. return None
  444. return {
  445. "left": left,
  446. "right": right,
  447. "top": top,
  448. "bottom": bottom,
  449. "width": width,
  450. "height": height,
  451. }
  452. except Exception:
  453. return None
  454. def _xpath_exists_meaningfully(self, d, xpath, min_width=24, min_height=12):
  455. bounds = self._get_xpath_bounds(d, xpath)
  456. if not bounds:
  457. return False
  458. return bounds["width"] >= min_width and bounds["height"] >= min_height
  459. def _get_active_verification_xpaths(self, d):
  460. active = []
  461. active_containers = []
  462. container_xpaths = [
  463. '//*[contains(@resource-id, "captcha")]',
  464. '//*[contains(@resource-id, "yoda")]',
  465. '//*[@resource-id="puzzleSliderBox"]',
  466. '//*[@resource-id="puzzleImageMain"]',
  467. '//*[@resource-id="yodaBoxWrapper"]',
  468. '//*[@resource-id="yodaBox"]',
  469. '//*[contains(@resource-id, "verify")]',
  470. '//*[contains(@resource-id, "slider")]',
  471. '//*[contains(@resource-id, "puzzle")]',
  472. ]
  473. for xpath in container_xpaths:
  474. try:
  475. if self._xpath_exists_meaningfully(d, xpath, min_width=80, min_height=40):
  476. active_containers.append(xpath)
  477. except Exception:
  478. continue
  479. for xpath in self.popup_rules["verification"]:
  480. try:
  481. if self._xpath_exists_meaningfully(d, xpath):
  482. active.append(xpath)
  483. except Exception:
  484. continue
  485. if active:
  486. return list(dict.fromkeys(active))
  487. if active_containers:
  488. now = time.time()
  489. if now - self.last_verification_probe_log_ts >= 8:
  490. self.logger.info(
  491. "%s captcha container hit without explicit text rules: %s",
  492. self._device_tag(),
  493. active_containers[:3]
  494. )
  495. self.last_verification_probe_log_ts = now
  496. return list(dict.fromkeys(active_containers))
  497. fallback_xpaths = [
  498. '//*[contains(@text, "验证")]',
  499. '//*[contains(@text, "滑块")]',
  500. '//*[contains(@text, "拼图")]',
  501. '//*[contains(@text, "请点击")]',
  502. ]
  503. for xpath in fallback_xpaths:
  504. try:
  505. if self._xpath_exists_meaningfully(d, xpath):
  506. active.append(xpath)
  507. except Exception:
  508. continue
  509. return list(dict.fromkeys(active))
  510. def _confirm_active_verification_xpaths(self, d, rounds=3, interval=0.35, min_hits=2):
  511. hit_counter = {}
  512. for idx in range(rounds):
  513. for xpath in self._get_active_verification_xpaths(d):
  514. hit_counter[xpath] = hit_counter.get(xpath, 0) + 1
  515. if idx < rounds - 1:
  516. time.sleep(interval)
  517. confirmed = [xpath for xpath, hits in hit_counter.items() if hits >= min_hits]
  518. return list(dict.fromkeys(confirmed))
  519. def _wait_verification_cleared(
  520. self,
  521. d,
  522. timeout=120,
  523. stable_rounds=3,
  524. interval=1.2,
  525. solve_retry_interval=6
  526. ):
  527. """
  528. 必须连续 stable_rounds 次都检测不到验证码,才认为真正处理完成。
  529. """
  530. start = time.time()
  531. stable_count = 0
  532. last_active = []
  533. last_retry_solve_ts = 0
  534. while self.running and time.time() - start < timeout:
  535. active = self._get_active_verification_xpaths(d)
  536. if active:
  537. last_active = active
  538. stable_count = 0
  539. now = time.time()
  540. # 卡住时持续重试自动验证码处理,避免"只处理一次后一直挂起"
  541. if now - last_retry_solve_ts >= solve_retry_interval:
  542. if self._try_auto_solve_verification(d):
  543. self.logger.info("%s captcha auto-solver retried", self._device_tag())
  544. last_retry_solve_ts = now
  545. else:
  546. stable_count += 1
  547. if stable_count >= stable_rounds:
  548. return True, []
  549. time.sleep(interval)
  550. remaining = self._confirm_active_verification_xpaths(d, rounds=4, interval=0.4, min_hits=2)
  551. if not remaining:
  552. return True, []
  553. return False, remaining or last_active
  554. def _get_active_simple_popups(self, d):
  555. rules = self.popup_rules["simple"]
  556. if not rules:
  557. return []
  558. active = []
  559. for xpath, desc in rules:
  560. try:
  561. exists = bool(d.xpath(xpath).exists)
  562. except Exception:
  563. exists = False
  564. if exists:
  565. active.append((xpath, desc))
  566. return active
  567. def _handle_special_verify_pages(self, d):
  568. """定向处理验证码后的异常页,避免误点通用按钮。"""
  569. rules = [
  570. (
  571. '//*[@text="您的网络好像不太给力,请稍后再试"]',
  572. '//*[@text="重新加载"]',
  573. "网络不给力页-点击重新加载",
  574. ),
  575. (
  576. '//*[contains(@text, "verify.meituan.com/v2/app/general_page")]',
  577. '//*[@text="关闭页面"]',
  578. "general_page页-点击关闭页面",
  579. ),
  580. ]
  581. for detect_xpath, click_xpath, desc in rules:
  582. try:
  583. if d.xpath(detect_xpath).exists and d.xpath(click_xpath).exists:
  584. self.logger.info("%s 检测到%s", self._device_tag(), desc)
  585. d.xpath(click_xpath).click()
  586. return True
  587. except Exception:
  588. continue
  589. return False
  590. def check_and_handle_popup(self):
  591. d = self.spider.d
  592. # 0. 处理验证码后的异常页(定向按钮)
  593. if self._handle_special_verify_pages(d):
  594. return True
  595. # 1. 处理简单弹窗
  596. for xpath, desc in self._get_active_simple_popups(d):
  597. if self._is_recent_click(xpath):
  598. continue
  599. try:
  600. self.logger.info("%s 检测到弹窗: %s", self._device_tag(), desc)
  601. d.xpath(xpath).click()
  602. return True
  603. except Exception:
  604. continue
  605. # 2. 处理验证码弹窗
  606. active_verification = self._confirm_active_verification_xpaths(d, rounds=3, interval=0.35, min_hits=2)
  607. if active_verification:
  608. if not self.pausing.is_set():
  609. # 记录验证码出现时间戳,统计1小时窗口内出现次数
  610. now = time.time()
  611. self.captcha_appearance_timestamps.append(now)
  612. cutoff = now - self.CAPTCHA_APPEARANCE_WINDOW
  613. self.captcha_appearance_timestamps = [
  614. ts for ts in self.captcha_appearance_timestamps if ts > cutoff
  615. ]
  616. captcha_appearance_count = len(self.captcha_appearance_timestamps)
  617. self.last_verification_time = now
  618. self.verification_count += 1
  619. self.logger.warning(
  620. "%s 验证码弹窗触发(%s),1小时内第%s次,等待处理完成后再继续...",
  621. self._device_tag(),
  622. active_verification[:3],
  623. captcha_appearance_count,
  624. )
  625. self.pausing.set() # 通知主线程暂停
  626. if self._try_auto_solve_verification(d):
  627. self.logger.info("%s captcha auto-solver triggered", self._device_tag())
  628. cleared, remaining = self._wait_verification_cleared(d)
  629. if cleared:
  630. self.logger.info("%s 验证码已处理,准备恢复任务", self._device_tag())
  631. self.pausing.clear() # 放行主线程
  632. else:
  633. # 超时未清除:计数累加,主线程继续阻塞,等待下一轮处理或达到上限
  634. self.verification_count += 1
  635. self.logger.warning(
  636. "%s 验证码处理超时(第%s次),继续阻塞主线程: %s",
  637. self._device_tag(),
  638. self.verification_count,
  639. remaining[:3]
  640. )
  641. # 1小时内验证码出现次数上限检查
  642. captcha_appearance_count = len(self.captcha_appearance_timestamps)
  643. if captcha_appearance_count >= self.MAX_CAPTCHA_APPEARANCES:
  644. self.logger.error(
  645. "%s 1小时内验证码出现%s次,达到上限%s,终止任务",
  646. self._device_tag(),
  647. captcha_appearance_count,
  648. self.MAX_CAPTCHA_APPEARANCES,
  649. )
  650. self.captcha_appearance_limit_reached = True
  651. self.pausing.clear()
  652. self.running = False
  653. return True
  654. # 重试上限检查(放在外面,超时累加计数后也能触发)
  655. if self.verification_count > self.MAX_VERIFICATION_RETRY:
  656. self.logger.error("%s 验证码重试超限(%s次),终止任务", self._device_tag(), self.verification_count)
  657. self.pausing.clear()
  658. self.running = False
  659. return True
  660. # 3. 处理广告弹窗(点击右上角)
  661. if d.xpath('//*[contains(@text, "广告")]').exists:
  662. w, h = d.info['displayWidth'], d.info['displayHeight']
  663. d.click(w - 50, 50)
  664. self.logger.info("%s 关闭广告弹窗", self._device_tag())
  665. return True
  666. return False
  667. def stop(self):
  668. self.running = False
  669. class MTScreenshot:
  670. def __init__(self, d, oss_config, search_key, title_key, scroll_times=4, compress_quality=7, resize_ratio=0.8,device_id=None,
  671. monitor=None):
  672. self.device_id = device_id
  673. # 接收外部已连接好的u2设备实例
  674. self.d = d
  675. self.is_high_res = is_high_resolution_device(d)
  676. self.search_key = search_key # 添加这行
  677. self.title_key = title_key
  678. # 优先复用外部监控,避免频繁创建监控线程
  679. self.monitor = monitor
  680. self.loggerMT = logging.getLogger()
  681. # 日志初始化
  682. self.logger = self._init_logger()
  683. # OSS配置与初始化(核心配置,无冗余)
  684. self.oss_config = oss_config
  685. self.oss_bucket = self._init_oss_bucket()
  686. # 截图核心参数
  687. self.scroll_times = scroll_times
  688. self.compress_quality = compress_quality
  689. self.resize_ratio = resize_ratio
  690. def _init_logger(self):
  691. # 极简日志配置,仅保留必要输出
  692. logger = logging.getLogger("mt_screenshot")
  693. logger.setLevel(logging.INFO)
  694. logger.handlers.clear()
  695. handler = logging.StreamHandler()
  696. handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
  697. logger.addHandler(handler)
  698. return logger
  699. def _init_oss_bucket(self):
  700. # 仅做OSS配置校验和Bucket连接,无额外功能
  701. access_key_id = self.oss_config.get("access_key_id")
  702. access_key_secret = self.oss_config.get("access_key_secret")
  703. endpoint = self.oss_config.get("endpoint")
  704. bucket_name = self.oss_config.get("bucket_name")
  705. if not all([access_key_id, access_key_secret, endpoint, bucket_name]):
  706. self.logger.warning("OSS配置不完整,无法上传")
  707. return None
  708. # 进程内复用同一套配置的Bucket,避免重复连接
  709. cache_key = (access_key_id, access_key_secret, endpoint, bucket_name)
  710. with oss_bucket_cache_lock:
  711. cached_bucket = oss_bucket_cache.get(cache_key)
  712. if cached_bucket is not None:
  713. self.logger.info("复用已缓存的OSS Bucket连接")
  714. return cached_bucket
  715. try:
  716. auth = oss2.Auth(access_key_id, access_key_secret)
  717. bucket = oss2.Bucket(auth, endpoint, bucket_name)
  718. bucket.get_bucket_info() # 验证连接
  719. with oss_bucket_cache_lock:
  720. oss_bucket_cache[cache_key] = bucket
  721. self.logger.info("OSS Bucket连接成功")
  722. return bucket
  723. except Exception as e:
  724. self.logger.error(f"OSS Bucket连接失败: {e}")
  725. return None
  726. def _upload_to_oss(self, local_path):
  727. # 极简上传逻辑,仅返回OSS URL或None
  728. if not self.oss_bucket or not os.path.exists(local_path):
  729. return None
  730. file_name = os.path.basename(local_path)
  731. safe_name = re.sub(r'[^\w\.\-]', '_', file_name)
  732. oss_key = f"{self.oss_config.get('oss_prefix', 'scrape_data/')}{safe_name}"
  733. try:
  734. oss2.resumable_upload(self.oss_bucket, oss_key, local_path)
  735. # 生成并返回完整OSS URL
  736. oss_file_url = f"https://{self.oss_config['bucket_name']}.{self.oss_config['endpoint']}/{urllib.parse.quote(oss_key, safe='/')}"
  737. return oss_file_url
  738. except Exception as e:
  739. self.logger.error(f"OSS上传失败: {e}")
  740. return None
  741. def safe_exec(self, func, *args, **kwargs):
  742. """
  743. 万能安全壳:执行 func 前检查验证码,
  744. 若监控线程已置位 pausing,则一直阻塞直到放行。
  745. """
  746. while self.monitor is not None and self.monitor.pausing.is_set():
  747. time.sleep(1)
  748. # 执行真正逻辑
  749. return func(*args, **kwargs)
  750. def _get_title(self):
  751. # try:
  752. def _inner():
  753. print(f'获取商品title时的搜索关键字:{self.title_key}')
  754. # 初始化
  755. drugs_name = ''
  756. specifications = ''
  757. title = ''
  758. # 循环的获取title为了有时间来处理人机验证
  759. for m in range(1, 6000):
  760. if self.d.xpath(f'//*[contains(@text, "{self.title_key}")]').exists:
  761. title = self.safe_exec(
  762. lambda: self.d.xpath(f'//*[contains(@text, "{self.title_key}")]').text
  763. )
  764. self.loggerMT.info(f"第{m}次获取title成功")
  765. print(f"第{m}次获取title成功")
  766. break
  767. else:
  768. time.sleep(1)
  769. # return drugs_name, specifications
  770. title = title[1:] if title.startswith('0') else title
  771. print(f'获取到药品标题:{title}')
  772. match = re.match(r'(\[[^\]]+\])(.*?)\s*((?:\d+\S*|\(.+))$', title)
  773. if match:
  774. drugs_name = title
  775. specifications = match.group(3).strip()
  776. print("药品名:", drugs_name)
  777. print("规格:", specifications)
  778. # print('完整药名:', drugs_name + specifications)
  779. return drugs_name # , specifications
  780. else:
  781. drugs_name = title
  782. specifications = ''
  783. return drugs_name
  784. # 用 safe_exec 包装内部逻辑,确保验证码阻塞
  785. return self.safe_exec(_inner)
  786. def _merge_screenshots(self, screens):
  787. # 仅拼接截图,无额外功能
  788. if len(screens) == 1:
  789. return screens[0].convert('RGB')
  790. rgb_screens = [s.convert('RGB') for s in screens]
  791. total_width = rgb_screens[0].width
  792. total_height = sum(s.height for s in rgb_screens)
  793. merged_img = Image.new('RGB', (total_width, total_height))
  794. y_offset = 0
  795. for img in rgb_screens:
  796. merged_img.paste(img, (0, y_offset))
  797. y_offset += img.height
  798. return merged_img
  799. def get_oss_url(self, title=None):
  800. """核心方法:截图+临时本地保存+上传OSS+上传成功删本地文件+返回OSS URL,可直接赋值给oss_file"""
  801. local_file_path = None
  802. try:
  803. # 1. 优先使用外部已采集标题,避免重复读取页面标题
  804. title = str(title or "").strip()
  805. if not title:
  806. title = self._get_title()
  807. self.logger.info(f"获取标题: {title[:20]}..." if title else "未获取到标题")
  808. else:
  809. self.logger.info(f"使用外部标题: {title[:20]}...")
  810. # 2. 生成本地文件路径
  811. timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + '_mt_' + self.device_id
  812. safe_title = re.sub(r'[\\/*?:"<>|]', '_', title)
  813. local_dir = "../scrape_data"
  814. os.makedirs(local_dir, exist_ok=True)
  815. local_file_path = os.path.join(local_dir, f"{timestamp}_{safe_title}.jpg")
  816. # 3. 滚动截图
  817. screen_list = [self.d.screenshot()]
  818. w, h = self.d.window_size()
  819. for i in range(self.scroll_times):
  820. # 可能滑动距离太短,截不到店名。原本是0.8
  821. # self.d.swipe(w // 2, h * 0.9, w // 2, h * 0.1, duration=random.uniform(0.6, 1.2))
  822. if self.is_high_res:
  823. self.d.swipe(w // 2, h * 0.75, w // 2, h * 0.25, duration=random.uniform(0.8, 1.5))
  824. else:
  825. self.d.swipe(w // 2, h * 0.85, w // 2, h * 0.15, duration=random.uniform(0.8, 1.5))
  826. time.sleep(random.uniform(2.0, 4.0))
  827. screen_list.append(self.d.screenshot())
  828. if self.d(textContains='商家服务').exists:
  829. # 看情况是否需要补滑
  830. break
  831. # 4. 拼接+压缩+保存
  832. merged_img = self._merge_screenshots(screen_list)
  833. if 0.1 < self.resize_ratio < 1.0:
  834. new_size = (int(merged_img.width * self.resize_ratio), int(merged_img.height * self.resize_ratio))
  835. resample_mode = Image.Resampling.LANCZOS if hasattr(Image, 'Resampling') else Image.LANCZOS
  836. merged_img = merged_img.resize(new_size, resample_mode)
  837. # 临时保存到本地
  838. merged_img.save(local_file_path, format='JPEG', quality=self.compress_quality)
  839. merged_img.close() # 释放长图句柄
  840. # 5. 上传OSS
  841. oss_url = self._upload_to_oss(local_file_path)
  842. # 6. 核心:OSS上传成功后,删除本地临时文件
  843. if oss_url is not None:
  844. try:
  845. self.logger.info(f"✅ OSS上传成功,已删除本地临时文件: {local_file_path}")
  846. except Exception as e:
  847. self.logger.warning(f"⚠️ OSS上传成功,但删除本地文件失败: {e}")
  848. return oss_url
  849. except Exception as e:
  850. self.logger.error(f"截图/上传失败: {e}")
  851. return None
  852. class MT:
  853. def __init__(
  854. self,
  855. key,
  856. title_key,
  857. spec_list,
  858. brand,
  859. sort=None,
  860. collect_range=None,
  861. page_range=None,
  862. workflow_retry_limit=None,
  863. workflow_error_action=None,
  864. platform=None,
  865. task_id=None,
  866. enterprise_id=None,
  867. sampling_cycle=None,
  868. sampling_start_time=None,
  869. sampling_end_time=None,
  870. count=None,
  871. collect_equipment_id=None,
  872. device_name=None,
  873. collect_equipment_account_id=None,
  874. collect_region_id=None,
  875. collect_round=None,
  876. scheduler=None,
  877. ):
  878. self.scheduler = scheduler
  879. self.package_name = Config.PACKAGE_NAME
  880. self.access_token = get_access_token()
  881. self.APP_ID = '116857964'
  882. self.API_KEY = '1gAzACJOAr7BeILKqkqPOETh'
  883. self.SECRET_KEY = 'ZNArANb9GwJYgLKg4EfYhukKBfPdl1n3'
  884. self.client = AipOcr(self.APP_ID, self.API_KEY, self.SECRET_KEY)
  885. self.area_service = AreaService("city.json", "addr_prefix.json")
  886. self.table_name = "retrieve_scrape_data"
  887. self.shop_table_name = "retrieve_scrape_shop_info"
  888. self.loggerMT = logging.getLogger()
  889. self.logger = self.loggerMT
  890. self.task_id = task_id
  891. self.enterprise_id = enterprise_id
  892. self.platform = platform
  893. self.collect_equipment_id = collect_equipment_id
  894. self.device_name = device_name
  895. self.page = 0
  896. self.search_key = str(key or "").strip()
  897. self.title_key = str(title_key or "").strip()
  898. self.spec_list = [str(spec).strip() for spec in (spec_list or []) if str(spec).strip()]
  899. self.brand = str(brand or "").strip()
  900. self.sort = sort
  901. self.collect_equipment_account_id = collect_equipment_account_id
  902. self.collect_region_id = collect_region_id
  903. self.collect_round = collect_round
  904. self.sampling_cycle = sampling_cycle
  905. self.sampling_start_time = sampling_start_time
  906. self.sampling_end_time = sampling_end_time
  907. # self.count = parse_optional_int(count, None)
  908. self.count = 200
  909. if self.count is not None and self.count <= 0:
  910. self.count = None
  911. self.loggerMT.info(
  912. "[MT初始化] task_id=%s device_id=%s collect_equipment_id=%s platform=%s enterprise_id=%s "
  913. "sampling_cycle=%s sampling_start_time=%s sampling_end_time=%s target_count=%s",
  914. self.task_id,
  915. getattr(self, "device_id", None),
  916. self.collect_equipment_id,
  917. self.platform,
  918. self.enterprise_id,
  919. self.sampling_cycle,
  920. self.sampling_start_time,
  921. self.sampling_end_time,
  922. self.count,
  923. )
  924. self.collect_range = self.normalize_collect_range(collect_range)
  925. self.page_range = self.normalize_page_range(page_range)
  926. self.sort_key = 0
  927. self.unrelated_data = 0
  928. self.shop_data_num = 0
  929. self.collected_data_count = 0
  930. self.collected_count_lock = threading.Lock()
  931. self.target_count_reached_event = threading.Event()
  932. self.max_unrelated_data = 15
  933. self.request_error_count = 0
  934. self.request_error_threshold = REQUEST_ERROR_STOP_THRESHOLD
  935. self.product_link_missing_count = 0
  936. self.product_link_missing_threshold = PRODUCT_LINK_STOP_THRESHOLD
  937. # 风控卡死检测:"加载更多"按钮检测
  938. self.load_more_check_rounds = 4
  939. self.load_more_check_interval = 30
  940. self.app_closed = False
  941. self.collection_cursor = {"page_no": 1, "item_index": 0}
  942. self.workflow_retry_limit = workflow_retry_limit or {
  943. "start_app": 3,
  944. "open_product_list_page": 3,
  945. "collect_single_product": 3,
  946. }
  947. self.workflow_error_action = workflow_error_action or {
  948. "start_app": "start_app",
  949. "open_product_list_page": "start_app",
  950. "collect_single_product": "back_to_list_page",
  951. }
  952. self.is_high_res = False # 连接设备后更新
  953. self.finish_reported = False
  954. self.post_process_executor = ThreadPoolExecutor(max_workers=4)
  955. self.post_process_futures = []
  956. self.post_process_lock = threading.Lock()
  957. self.max_pending_post_tasks = 80
  958. def get_collected_data_count(self):
  959. with self.collected_count_lock:
  960. return self.collected_data_count
  961. def has_reached_target_count(self):
  962. if self.count is None:
  963. return False
  964. if self.target_count_reached_event.is_set():
  965. return True
  966. return self.get_collected_data_count() >= self.count
  967. def mark_collected_data_saved(self):
  968. with self.collected_count_lock:
  969. self.collected_data_count += 1
  970. current_count = self.collected_data_count
  971. if self.count is not None and current_count >= self.count:
  972. self.target_count_reached_event.set()
  973. return current_count
  974. def finish_task_normally(self, end_page, reason):
  975. if not self.finish_reported and self.task_id:
  976. if self.scheduler:
  977. self.scheduler.post_report({
  978. "task_id": self.task_id,
  979. "platform": str(self.platform),
  980. "username": self.scheduler.username,
  981. "current_page": end_page,
  982. "crawled_count": self.get_collected_data_count(),
  983. "is_finished": 1,
  984. })
  985. self.finish_reported = True
  986. self.wr_re("删", self.device_id)
  987. print(reason)
  988. self.close()
  989. return True
  990. def finish_task_abnormally(self, end_page, reason, finish_status=0):
  991. if not self.finish_reported and self.task_id:
  992. self.scheduler.post_report({
  993. "task_id": self.task_id,
  994. "platform": str(self.platform),
  995. "username": self.scheduler.username,
  996. "current_page": end_page,
  997. "crawled_count": self.get_collected_data_count(),
  998. "is_finished": 0,
  999. "need_reassign": 1,
  1000. "exception_type": 5,
  1001. "remark": reason,
  1002. })
  1003. self.finish_reported = True
  1004. print(reason)
  1005. self.close()
  1006. return False
  1007. def _progress_file_path(self, device_id=None):
  1008. target_device = device_id or getattr(self, "device_id", None)
  1009. if not target_device:
  1010. return None
  1011. return f'./ycwj/{target_device}_{self.title_key}.txt'
  1012. @staticmethod
  1013. def normalize_collect_range(collect_range):
  1014. if not collect_range:
  1015. return None
  1016. start = None
  1017. end = None
  1018. if isinstance(collect_range, dict):
  1019. start = collect_range.get("start")
  1020. end = collect_range.get("end")
  1021. elif isinstance(collect_range, (list, tuple)) and len(collect_range) >= 2:
  1022. start, end = collect_range[0], collect_range[1]
  1023. elif isinstance(collect_range, str):
  1024. matched = re.match(r"^\s*(\d+(?:\.\d+)?)\s*[-,~]\s*(\d+(?:\.\d+)?)\s*$", collect_range)
  1025. if matched:
  1026. start, end = matched.group(1), matched.group(2)
  1027. try:
  1028. start = float(start)
  1029. end = float(end)
  1030. except (TypeError, ValueError):
  1031. return None
  1032. if start < 0 or end < 0:
  1033. return None
  1034. if start > end:
  1035. start, end = end, start
  1036. return {"start": start, "end": end}
  1037. @staticmethod
  1038. def normalize_page_range(page_range):
  1039. if not page_range:
  1040. return None
  1041. start = None
  1042. end = None
  1043. if isinstance(page_range, dict):
  1044. start = page_range.get("start")
  1045. end = page_range.get("end")
  1046. elif isinstance(page_range, (list, tuple)) and len(page_range) >= 2:
  1047. start, end = page_range[0], page_range[1]
  1048. elif isinstance(page_range, str):
  1049. matched = re.match(r"^\s*[\[\(]?\s*(\d+)\s*[,,\-~]\s*(\d+)\s*[\]\)]?\s*$", page_range)
  1050. if matched:
  1051. start, end = matched.group(1), matched.group(2)
  1052. try:
  1053. start = int(float(start))
  1054. end = int(float(end))
  1055. except (TypeError, ValueError):
  1056. return None
  1057. if start <= 0 or end <= 0:
  1058. return None
  1059. if start > end:
  1060. start, end = end, start
  1061. return {"start": start, "end": end}
  1062. def stop_app(self):
  1063. if getattr(self, "d", None) is None or self.app_closed:
  1064. return
  1065. try:
  1066. self.d.app_stop(self.package_name)
  1067. self.app_closed = True
  1068. except Exception as e:
  1069. self.loggerMT.warning(f"关闭应用失败: {e}")
  1070. time.sleep(1)
  1071. def start_app(self):
  1072. self.d.app_start(self.package_name)
  1073. self.app_closed = False
  1074. time.sleep(1)
  1075. def restart_app(self):
  1076. self.stop_app()
  1077. self.start_app()
  1078. def close(self):
  1079. self.stop_app()
  1080. def li_or_lo(self, key="升序"):
  1081. """
  1082. 排序操作:升序或降序
  1083. :param key: "升序" 或 "降序"
  1084. """
  1085. if key == "升序":
  1086. # 增加重试机制,最多尝试3次
  1087. max_retries = 3
  1088. for attempt in range(max_retries):
  1089. try:
  1090. # 1. 点击“综合”标签
  1091. # 优先使用text定位,若失败可尝试其他属性
  1092. comprehensive = self.d.xpath('//*[@text="综合"]')
  1093. if comprehensive.exists:
  1094. comprehensive.click(timeout=3) # 等待元素可点击
  1095. time.sleep(0.5)
  1096. # 2. 点击“总价低到高”选项
  1097. low_to_high = self.d.xpath('//*[@text="总价低到高"]')
  1098. if low_to_high.exists:
  1099. low_to_high.click(timeout=3)
  1100. time.sleep(0.7)
  1101. self.sort_key += 1
  1102. self.logger.info("排序已切换为升序(总价低到高)")
  1103. return # 成功执行后退出
  1104. except Exception as e:
  1105. self.logger.warning(f"升序排序尝试 {attempt+1}/{max_retries} 失败: {e}")
  1106. # 如果失败,等待并重试
  1107. time.sleep(1)
  1108. # 若最后一次重试仍失败,抛出异常
  1109. if attempt == max_retries - 1:
  1110. self.logger.error("升序排序最终失败,请检查UI元素")
  1111. raise
  1112. elif key == "降序":
  1113. # 当前版本不支持降序,记录日志但不执行操作
  1114. self.logger.warning('2026_05版本不存在降序,请勿调用')
  1115. print('2026_05版本不存在降序,请勿调用')
  1116. # 可根据需要选择抛出异常或直接返回
  1117. # raise NotImplementedError("降序功能未实现")
  1118. else:
  1119. raise ValueError(f"无效的排序参数: {key},仅支持 '升序' 或 '降序'")
  1120. def wr_re(self, mod, device_id, sort=None, page=None):
  1121. file_path = self._progress_file_path(device_id)
  1122. if not file_path:
  1123. return None
  1124. if mod == "写":
  1125. try:
  1126. data = {
  1127. "page": page if page else "",
  1128. "sort": sort if sort else "",
  1129. }
  1130. os.makedirs(os.path.dirname(file_path), exist_ok=True)
  1131. with open(file_path, 'w', encoding='utf-8') as f:
  1132. json.dump(data, f, ensure_ascii=False, indent=2)
  1133. print(f"进度保存成功:{sort},{page}页")
  1134. except Exception as e:
  1135. print("保存进度失败")
  1136. elif mod == "读":
  1137. self.li_or_lo()
  1138. try:
  1139. if not os.path.exists(file_path):
  1140. return None
  1141. with open(file_path, 'r', encoding='utf-8') as f:
  1142. data = json.load(f)
  1143. i = 0
  1144. while True:
  1145. self.wait_for_ready(getattr(self, "monitor", None))
  1146. if i == data['page']:
  1147. self.page = data['page']
  1148. print("当前页", self.page)
  1149. break
  1150. else:
  1151. i += 1
  1152. if self.is_high_res:
  1153. self.d.drag(300, 2600, 300, 400, 1)
  1154. else:
  1155. self.d.drag(300, 1400, 300, 400, 1)
  1156. return data
  1157. except Exception as e:
  1158. print(f"读取进度失败")
  1159. return None
  1160. elif mod == "删":
  1161. try:
  1162. if os.path.exists(file_path):
  1163. os.remove(file_path)
  1164. print(f"进度文件已删除:{file_path}")
  1165. return True
  1166. except Exception as e:
  1167. print(f"删除进度文件失败: {e}")
  1168. return False
  1169. return None
  1170. # 任何一个spec满足都算有效
  1171. def is_link_spec_useful(self, product_title):
  1172. normalized_title = normalize_match_text(product_title)
  1173. if len(self.spec_list) == 0:
  1174. return True
  1175. for spec in self.spec_list:
  1176. if normalize_match_text(spec) in normalized_title:
  1177. return True
  1178. return False
  1179. # TODO 继续优化这里的判断逻辑,可以考虑搭配config的修改
  1180. def is_link_useful(self, product_title):
  1181. normalized_title = normalize_match_text(product_title)
  1182. normalized_title_key = normalize_match_text(self.title_key)
  1183. normalized_brand = normalize_match_text(self.brand)
  1184. if normalized_title_key != "" and normalized_title_key not in normalized_title:
  1185. print(f"当前商品名称:{product_title} 不包含{self.title_key}关键字")
  1186. return False
  1187. if normalized_brand != "" and normalized_brand not in normalized_title:
  1188. print(f"当前商品名称:{product_title} 不包含{self.brand}品牌")
  1189. return False
  1190. if not self.is_link_spec_useful(product_title):
  1191. print(f"当前商品名称:{product_title} 不包含{self.spec_list}品规")
  1192. return False
  1193. return True
  1194. @staticmethod
  1195. def get_sleep_time():
  1196. # return random.randint(5, 8)
  1197. # return 1
  1198. return random.uniform(0.5, 1.0)
  1199. @staticmethod
  1200. def get_current_date():
  1201. return datetime.datetime.now().strftime('%Y/%m/%d')
  1202. def get_shop_name_from_current_page(self):
  1203. """
  1204. 仅从当前商品详情页读取店铺名,不做任何页面跳转。
  1205. """
  1206. shop_name = self.get_first_text_by_xpaths([
  1207. '//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',
  1208. '//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',
  1209. ])
  1210. if shop_name:
  1211. print(f'获取到店铺名:{shop_name}')
  1212. return shop_name
  1213. def get_shop_name(self):
  1214. """
  1215. 获取店铺名
  1216. :return:
  1217. """
  1218. shop_name = self.get_shop_name_from_current_page()
  1219. if shop_name:
  1220. return shop_name
  1221. try:
  1222. # 点击店铺进入后获取店铺名称
  1223. print("点击店铺进入后获取店铺名称")
  1224. self.enter_shop()
  1225. 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'
  1226. if self.d.xpath(shop_xpath).exists:
  1227. shop_name = self.d.xpath(shop_xpath).text
  1228. self.swipe_back(1)
  1229. return shop_name
  1230. shop_name = ''
  1231. return shop_name
  1232. except Exception as e:
  1233. print(f'获取店铺名出错:{e}')
  1234. return ''
  1235. def get_qualification_number(self):
  1236. """
  1237. 获取资质编号
  1238. :return:
  1239. """
  1240. try:
  1241. # 方法1:精准 XPath
  1242. elem = self.d.xpath(
  1243. '//*[@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]')
  1244. if elem.exists:
  1245. text = elem.text
  1246. if text:
  1247. return text.replace('资质编号:', '').strip()
  1248. # 方法2:模糊匹配任意包含"资质编号"的 TextView
  1249. elem2 = self.d.xpath('//android.widget.TextView[contains(@text, "资质编号")]')
  1250. if elem2.exists:
  1251. text = elem2.text
  1252. if text:
  1253. return text.replace('资质编号:', '').strip()
  1254. # 方法3:更通用的包含匹配(不限 TextView)
  1255. elem3 = self.d.xpath('//*[contains(@text, "资质编号")]')
  1256. if elem3.exists:
  1257. text = elem3.text
  1258. if text:
  1259. return text.replace('资质编号:', '').strip()
  1260. return None
  1261. except Exception as e:
  1262. print(f"获取资质编号失败: {e}")
  1263. return None
  1264. def get_shop_address(self):
  1265. try:
  1266. shop_address_xpaths = [
  1267. '//*[@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',
  1268. '//*[@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'
  1269. ]
  1270. shop_address = self.get_first_text_by_xpaths(shop_address_xpaths)
  1271. print(f'111-获取到店铺地址:{shop_address}')
  1272. if '发货时间' in shop_address:
  1273. print(f'店铺地址包含发货时间,再次获取店铺地址')
  1274. shop_address = self._read_xpath_text(shop_address_xpaths[1])
  1275. if shop_address:
  1276. print(f'222-获取到店铺地址:{shop_address}')
  1277. else:
  1278. print(f'222-xpath2获取店铺地址失败')
  1279. print(f'333-获取到店铺地址:{shop_address}')
  1280. if "近30天平均发货" not in shop_address :
  1281. return shop_address
  1282. else:
  1283. return ""
  1284. except:
  1285. print(f'获取店铺地址出错-get_shop_address')
  1286. return None
  1287. def execute_db_write(self, sql, params, action_desc, max_retries=5):
  1288. for attempt in range(max_retries):
  1289. conn = None
  1290. try:
  1291. conn = get_mysql()
  1292. with conn.cursor() as cur:
  1293. cur.execute(sql, params)
  1294. conn.commit()
  1295. print(f"{action_desc}成功")
  1296. return True
  1297. except Exception as e:
  1298. print(f'{action_desc}异常 (尝试 {attempt + 1}/{max_retries}): {e}')
  1299. if conn:
  1300. conn.rollback()
  1301. if attempt == max_retries - 1:
  1302. print(f"{action_desc}失败,达到最大重试次数")
  1303. return False
  1304. time.sleep(2)
  1305. finally:
  1306. if conn:
  1307. conn.close()
  1308. def query_exists(self, sql, params, error_desc):
  1309. conn = None
  1310. try:
  1311. conn = get_mysql()
  1312. with conn.cursor() as cur:
  1313. cur.execute(sql, params)
  1314. return bool(cur.fetchone())
  1315. except Exception as e:
  1316. print(f"{error_desc}错误: {str(e)}")
  1317. return None
  1318. finally:
  1319. if conn:
  1320. conn.close()
  1321. def save_to_database(self, data):
  1322. add_sql = f"""
  1323. INSERT IGNORE INTO {self.table_name} (
  1324. enterprise_id, platform_id, platform_item_id, province_id, city_id,
  1325. province_name, city_name, area_info, product_name, product_specs,
  1326. one_box_price, manufacture_date, expiry_date, manufacturer, approval_number,
  1327. is_sold_out, online_posting_count, continuous_listing_count, link_url,
  1328. store_name, store_url, shipment_province_id, shipment_province_name,
  1329. shipment_city_id, shipment_city_name, company_name, qualification_number,
  1330. scrape_date, min_price, number, sales, inventory, snapshot_url,
  1331. product_brand, search_name, insert_time ,update_time, collect_config_info,
  1332. collect_equipment_account_id ,collect_region_id ,collect_round,
  1333. shop_id, company_id, task_id
  1334. ) VALUES (
  1335. %s, %s, %s, %s, %s,
  1336. %s, %s, %s, %s, %s,
  1337. %s, %s, %s, %s, %s,
  1338. %s, %s, %s, %s,
  1339. %s, %s, %s, %s,
  1340. %s, %s, %s, %s,
  1341. %s, %s, %s, %s, %s, %s,
  1342. %s, %s, %s, %s, %s, %s, %s,
  1343. %s,
  1344. %s, %s,
  1345. %s
  1346. )
  1347. """
  1348. store_name = data.get('store_name', '')
  1349. params = (
  1350. data['enterprise_id'],
  1351. data['platform_id'],
  1352. data['platform_item_id'],
  1353. data['province_id'],
  1354. data['city_id'],
  1355. data['province_name'],
  1356. data['city_name'],
  1357. data['area_info'],
  1358. data['product_name'],
  1359. data['product_specs'],
  1360. data['one_box_price'],
  1361. data['manufacture_date'],
  1362. data['expiry_date'],
  1363. data['manufacturer'],
  1364. data['approval_number'],
  1365. data['is_sold_out'],
  1366. data['online_posting_count'],
  1367. data['continuous_listing_count'],
  1368. data['link_url'],
  1369. store_name,
  1370. data['store_url'],
  1371. data['shipment_province_id'],
  1372. data['shipment_province_name'],
  1373. data['shipment_city_id'],
  1374. data['shipment_city_name'],
  1375. data['company_name'],
  1376. data['qualification_number'],
  1377. data['scrape_date'],
  1378. data['min_price'],
  1379. data['number'],
  1380. data['sales'],
  1381. data['inventory'],
  1382. data['snapshot_url'],
  1383. data['product_brand'],
  1384. data['search_name'],
  1385. data['insert_time'],
  1386. data['update_time'],
  1387. data['collect_config_info'],
  1388. data['collect_equipment_account_id'],
  1389. data['collect_region_id'],
  1390. data['collect_round'],
  1391. store_name, # shop_id = store_name
  1392. store_name, # company_id = store_name
  1393. data.get('task_id'),
  1394. )
  1395. return self.execute_db_write(add_sql, params, "保存商品数据到数据库")
  1396. def save_shop_info_to_database(self, data):
  1397. print(f'保存店铺数据到数据库:{data}')
  1398. now_str = time.strftime('%Y-%m-%d %H:%M:%S')
  1399. def _clean(v):
  1400. v = str(v or '').strip()
  1401. return '' if v in ('无', '無') else v
  1402. shop = str(data.get('shop') or '').strip()
  1403. contact_address = _clean(data.get('contact_address') or data.get('business_license_address'))
  1404. business_license_address = _clean(data.get('business_license_address') or contact_address)
  1405. qualification_number = _clean(data.get('qualification_number'))
  1406. business_license_company = _clean(data.get('business_license_company'))
  1407. scrape_date = str(data.get('scrape_date') or self.get_current_date()).strip()
  1408. platform = str(data.get('platform') or self.platform or '4').strip()
  1409. province = str(data.get('province') or '').strip()
  1410. city = str(data.get('city') or '').strip()
  1411. create_time = str(data.get('create_time') or now_str).strip()
  1412. update_time = str(data.get('update_time') or now_str).strip()
  1413. if self.shop_is_exists_database(shop, platform):
  1414. if ENABLE_SHOP_DEBUG:
  1415. print(f"[SHOP-DEBUG] save_shop_info_to_database: 命中UPDATE, shop={shop}")
  1416. existing = self.get_shop_info_from_database(shop, platform) or {}
  1417. existing_contact = str(existing.get("contact_address") or "").strip()
  1418. existing_biz_address = str(existing.get("business_license_address") or "").strip()
  1419. existing_company = str(existing.get("business_license_company") or "").strip()
  1420. existing_qn = str(existing.get("qualification_number") or "").strip()
  1421. existing_province = str(existing.get("province") or "").strip()
  1422. existing_city = str(existing.get("city") or "").strip()
  1423. contact_address = contact_address or existing_contact or existing_biz_address
  1424. business_license_address = business_license_address or existing_biz_address or existing_contact
  1425. business_license_company = business_license_company or existing_company
  1426. qualification_number = qualification_number or existing_qn
  1427. province = province or existing_province
  1428. city = city or existing_city
  1429. if (not province) and (not city):
  1430. match = self.area_service.search_area(
  1431. business_license_address or contact_address)
  1432. if match:
  1433. province = match.province or province
  1434. city = match.city or city
  1435. if ENABLE_SHOP_DEBUG:
  1436. print(
  1437. f"[SHOP-DEBUG] update payload: shop={shop}, "
  1438. f"company={business_license_company}, qn={qualification_number}, "
  1439. f"contact_address={contact_address}, biz_address={business_license_address}, "
  1440. f"province={province}, city={city}, platform={platform}"
  1441. )
  1442. update_sql = f"""
  1443. UPDATE {self.shop_table_name}
  1444. SET contact_address = %s,
  1445. qualification_number = %s,
  1446. business_license_company = %s,
  1447. business_license_address = %s,
  1448. scrape_date = %s,
  1449. platform = %s,
  1450. province = %s,
  1451. city = %s,
  1452. update_time = %s,
  1453. shop_id = %s,
  1454. company_id = %s
  1455. WHERE shop = %s
  1456. """
  1457. update_params = (
  1458. contact_address,
  1459. qualification_number,
  1460. business_license_company,
  1461. business_license_address,
  1462. scrape_date,
  1463. platform,
  1464. province,
  1465. city,
  1466. update_time,
  1467. shop, # shop_id = shop
  1468. shop, # company_id = shop
  1469. shop
  1470. )
  1471. return self.execute_db_write(update_sql, update_params, "更新店铺数据到数据库")
  1472. if ENABLE_SHOP_DEBUG:
  1473. print(f"[SHOP-DEBUG] save_shop_info_to_database: 命中INSERT, shop={shop}")
  1474. if ENABLE_SHOP_DEBUG:
  1475. print(
  1476. f"[SHOP-DEBUG] insert payload: shop={shop}, "
  1477. f"company={business_license_company}, qn={qualification_number}, "
  1478. f"contact_address={contact_address}, biz_address={business_license_address}, "
  1479. f"province={province}, city={city}, platform={platform}"
  1480. )
  1481. add_sql = f"""
  1482. INSERT INTO {self.shop_table_name}
  1483. (shop, contact_address, qualification_number, business_license_company, business_license_address, scrape_date, platform, province, city, create_time, update_time, shop_id, company_id)
  1484. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  1485. """
  1486. add_params = (
  1487. shop,
  1488. contact_address,
  1489. qualification_number,
  1490. business_license_company,
  1491. business_license_address,
  1492. scrape_date,
  1493. platform,
  1494. province,
  1495. city,
  1496. create_time,
  1497. update_time,
  1498. shop, # shop_id = shop
  1499. shop, # company_id = shop
  1500. )
  1501. return self.execute_db_write(add_sql, add_params, "保存店铺数据到数据库")
  1502. def swipe_back(self, no):
  1503. """
  1504. 返回
  1505. :param no: 回退次数
  1506. :return:
  1507. """
  1508. for idx in range(no):
  1509. self.d.press('back')
  1510. time.sleep(0.5)
  1511. def drug_price(self):
  1512. """
  1513. 获取药品价格
  1514. :return:
  1515. """
  1516. time.sleep(0.5)
  1517. try:
  1518. price_str = ""
  1519. price_int_xpath = '//*[@text="¥"]/../../android.widget.FrameLayout[2]/android.widget.TextView[1]'
  1520. price_decimal_xpath = '//*[@text="¥"]/../../android.widget.FrameLayout[3]/android.widget.TextView[1]'
  1521. price_int_node = self.d.xpath(price_int_xpath)
  1522. if price_int_node.exists and price_int_node.text:
  1523. price_str = price_int_node.text.strip()
  1524. price_decimal_node = self.d.xpath(price_decimal_xpath)
  1525. if price_decimal_node.exists and price_decimal_node.text:
  1526. price_str += price_decimal_node.text.strip()
  1527. if not price_str:
  1528. print('提取价格出错-->未获取到价格文本')
  1529. return None
  1530. # if self.d.xpath('//*[@text="优惠"]').exists:
  1531. # self.d.xpath('//*[@text="优惠"]').click()
  1532. # time.sleep(0.5)
  1533. # if self.d.xpath('//*[contains(@text, "现在购买") and contains(@text, "享受以下优惠") and contains(@text, "共省")]').exists:
  1534. # match = re.search(r'共省¥([\d.]+)', self.d.xpath('//*[contains(@text, "现在购买") and contains(@text, "享受以下优惠") and contains(@text, "共省")]').text)
  1535. # self.d.press("back")
  1536. # if match:
  1537. # save_amount = match.group(1)
  1538. # print(f"优惠金额: {save_amount}")
  1539. # price = float(Decimal(str(price_str)) + Decimal(str(save_amount)))
  1540. # if not price:
  1541. # price = float(price_str)
  1542. price = float(price_str)
  1543. print(f'获取到价格: {price}')
  1544. return price
  1545. except Exception as e:
  1546. print(f'提取价格出错-->{e}')
  1547. return None
  1548. def drug_sale_num(self):
  1549. """
  1550. 获取药品销量
  1551. :return:
  1552. """
  1553. try:
  1554. sales_element = self.d.xpath('//*[starts-with(@text,"已售")]')
  1555. if sales_element.exists:
  1556. sales_num_str = self.d.xpath('//*[starts-with(@text,"已售")]').text
  1557. sales_num_str = sales_num_str.replace("已售", "").strip()
  1558. # price = float(re.search(r'[\d\.]+', price_str).group())
  1559. print(f'获取到已售数量:{sales_num_str}')
  1560. return sales_num_str
  1561. return None
  1562. except Exception as e:
  1563. print(f'提取已售数量出错-->{e}')
  1564. return None
  1565. def restart_uiautomator_services(self, device_id):
  1566. """
  1567. 重启atx的uiautomator 服务
  1568. :param device_id:
  1569. :return:
  1570. """
  1571. stop_uiautomator_services = f'adb -s {device_id} shell /data/local/tmp/atx-agent server -d --stop'
  1572. start_uiautomator_services = f'adb -s {device_id} shell /data/local/tmp/atx-agent server -d'
  1573. subprocess.run(stop_uiautomator_services, capture_output=True, text=True, shell=True)
  1574. time.sleep(self.get_sleep_time())
  1575. subprocess.run(start_uiautomator_services, capture_output=True, text=True, shell=True)
  1576. time.sleep(self.get_sleep_time())
  1577. def reconnect_device(self):
  1578. """重启 atx-agent 并重新连接设备"""
  1579. try:
  1580. # 停止 atx-agent
  1581. subprocess.run(["adb", "-s", self.device_id, "shell",
  1582. "/data/local/tmp/atx-agent", "server", "-d", "--stop"],
  1583. capture_output=True, timeout=5)
  1584. time.sleep(1)
  1585. # 启动 atx-agent
  1586. subprocess.run(["adb", "-s", self.device_id, "shell",
  1587. "/data/local/tmp/atx-agent", "server", "-d"],
  1588. capture_output=True, timeout=5)
  1589. time.sleep(2)
  1590. # 重新连接 uiautomator2
  1591. self.d = u2.connect_usb(self.device_id)
  1592. self.is_high_res = is_high_resolution_device(self.d)
  1593. self.restart_uiautomator_services(self.device_id)
  1594. self.loggerMT.info("设备重连成功")
  1595. return True
  1596. except Exception as e:
  1597. self.loggerMT.error(f"设备重连失败: {e}")
  1598. return False
  1599. def connect_devices(self, device_id):
  1600. """
  1601. 连接设备
  1602. :return:
  1603. """
  1604. try:
  1605. self.device_id = device_id
  1606. self.d = u2.connect_usb(device_id)
  1607. self.is_high_res = is_high_resolution_device(self.d)
  1608. self.restart_uiautomator_services(device_id)
  1609. self.oss_config = {
  1610. "access_key_id": 'LTAI5t5pWgfa1BMztEuWBjdK',
  1611. "access_key_secret": 'wU7FLzEr1NqLg2rJrmAu7Ibn69np0u',
  1612. "endpoint": "oss-cn-shenzhen.aliyuncs.com", # 例:oss-cn-beijing.aliyuncs.com
  1613. "bucket_name": "zhijiayun-jiansuo",
  1614. "oss_prefix": "scrape_data/" # OSS中存放截图的前缀(虚拟文件夹)
  1615. }
  1616. print(f'连接到设备:{device_id}')
  1617. self.loggerMT.info(f'连接到设备:{device_id}')
  1618. except Exception as e:
  1619. print(f'{device_id} 连接错误: {e}')
  1620. self.loggerMT.info(f'{device_id} 连接错误: {e}')
  1621. raise Exception(e)
  1622. def get_ocr_res(self, img):
  1623. try:
  1624. # img地址
  1625. print(f'开始识别图片:{img}')
  1626. request_url = "https://aip.baidubce.com/rest/2.0/ocr/v1/business_license"
  1627. f = open(img, 'rb')
  1628. img = base64.b64encode(f.read())
  1629. params = {"image": img}
  1630. request_url = request_url + "?access_token=" + self.access_token
  1631. headers = {'content-type': 'application/x-www-form-urlencoded'}
  1632. response = requests.post(request_url, data=params, headers=headers)
  1633. if response:
  1634. res = response.json()
  1635. new_dic = dict()
  1636. for ite in res['words_result'].keys():
  1637. new_dic[ite] = res['words_result'][ite]['words']
  1638. print('资质数据信息', new_dic)
  1639. return new_dic
  1640. else:
  1641. return None
  1642. except:
  1643. return None
  1644. def remove_watermark(self, img_path):
  1645. """
  1646. 图片去水印(将水印部分变成白色背景)并将数据转化为二进制数据
  1647. :param img_path: 图片路径
  1648. :return: 二进制图片数据
  1649. """
  1650. img = cv2.imdecode(np.fromfile(img_path, dtype=np.uint8), -1)
  1651. endswith = os.path.splitext(img_path)[1]
  1652. new = np.clip(1.4057577998008846 * img - 38.33089999653017, 0, 255).astype(np.uint8)
  1653. _, img_binary = cv2.imencode(endswith, new)
  1654. return img_binary
  1655. def get_ocr_res_image(self, img):
  1656. try:
  1657. image = self.remove_watermark(img)
  1658. # image_file = open(img,'wb')
  1659. # image_file.write(images)
  1660. # res_image = self.client.basicAccurate(images) # 高精度
  1661. res_image = self.client.basicGeneral(image)
  1662. data = res_image.get('words_result', '')
  1663. print(f'百度api返回结果:{data}')
  1664. return data
  1665. except:
  1666. return None
  1667. def write_ocr_result_sidecar(self, image_path, ocr_res, source="business_license", extra=None):
  1668. """
  1669. 将 OCR 结果写到与图片同名的 sidecar 文件,便于逐图对照识别结果。
  1670. """
  1671. try:
  1672. if not image_path:
  1673. return ''
  1674. image_path = Path(str(image_path))
  1675. sidecar_path = image_path.with_suffix('.ocr.json')
  1676. payload = {
  1677. "image_path": str(image_path),
  1678. "source": source,
  1679. "success": bool(ocr_res),
  1680. "ocr_result": ocr_res if isinstance(ocr_res, (dict, list)) else {},
  1681. "extra": extra or {},
  1682. "created_at": time.strftime('%Y-%m-%d %H:%M:%S')
  1683. }
  1684. sidecar_path.parent.mkdir(parents=True, exist_ok=True)
  1685. with open(sidecar_path, 'w', encoding='utf-8') as f:
  1686. json.dump(payload, f, ensure_ascii=False, indent=2)
  1687. print(f'OCR结果已写入:{sidecar_path}')
  1688. return str(sidecar_path)
  1689. except Exception as e:
  1690. print(f'写OCR结果文件失败:{e}')
  1691. return ''
  1692. def screenshot_the_business_license(self, qualification_number):
  1693. screenshot_path = 'screenshot1.png'
  1694. self.d.screenshot(screenshot_path)
  1695. img = cv2.imread(screenshot_path)
  1696. # 指定裁剪区域 (left, top, right, bottom)
  1697. left = 0
  1698. top = 1026
  1699. right = 1220
  1700. bottom = 1904
  1701. cropped_img = img[top:bottom, left:right]
  1702. # 创建目录
  1703. SCREENSHOT_DIR = Path('screenshot') # 注意这里的变化和py文件同一级目录即可
  1704. SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
  1705. if qualification_number:
  1706. # cropped_screenshot_path = 'D:\\work\\dfwy_spider\\drug_data\\mt\\screenshot\\' + qualification_number + '.png'
  1707. cropped_screenshot_path = SCREENSHOT_DIR / f'{qualification_number}.png'
  1708. else:
  1709. cropped_screenshot_path = 'cropped_screenshot.png'
  1710. cv2.imwrite(str(cropped_screenshot_path), cropped_img)
  1711. return cropped_screenshot_path
  1712. def screenshot_instruction(self):
  1713. # 获取当前时间
  1714. current_time = datetime.datetime.now()
  1715. # 格式化为时分秒
  1716. time_str = current_time.strftime("%H-%M-%S")
  1717. # 生成随机的 8 位字符串
  1718. random_str = secrets.token_hex(4) # 生成 4 个字节的随机字符串,转换为 8 位十六进制字符串
  1719. screenshot_path = 'instructionscreenshot1-' + time_str + '-' + random_str + '.png'
  1720. self.d.screenshot(screenshot_path)
  1721. return screenshot_path
  1722. def extract_specification(self, text):
  1723. """提取药品规格信息"""
  1724. # 方法1:简单去除到期信息
  1725. pattern = r'^[^【]+'
  1726. match = re.search(pattern, text)
  1727. if match:
  1728. return match.group(0).strip()
  1729. return text
  1730. # 获取商品title
  1731. def get_title(self):
  1732. def _inner():
  1733. print(f'获取商品title时的搜索关键字:{self.title_key}')
  1734. # 初始化
  1735. drugs_name = ''
  1736. specifications = ''
  1737. title = ''
  1738. # 循环的获取title为了有时间来处理人机验证
  1739. for m in range(1, 6000):
  1740. if self.d.xpath(f'//*[contains(@text, "{self.title_key}")]').exists:
  1741. title = self.safe_exec(
  1742. lambda: self.d.xpath(f'//*[contains(@text, "{self.title_key}")]').text
  1743. )
  1744. print(f"第{m}次获取title成功")
  1745. break
  1746. else:
  1747. time.sleep(3)
  1748. # return drugs_name, specifications
  1749. title = title[1:] if title.startswith('0') else title
  1750. print(f'获取到药品标题:{title}')
  1751. # match = re.match(r'(\[[^\]]+\])(.*?)\s*((?:\d+\S*|\(.+))$', title)
  1752. match = re.match(r'^(?:0?)?(?:\[([^\]]+)\])?\s*(.*?)\s*(\d+[^\s]+)$', title)
  1753. if match:
  1754. # drugs_name = match.group(1).strip() + match.group(2).strip()
  1755. drugs_name = title
  1756. specifications = match.group(3).strip()
  1757. print("药品名:", drugs_name)
  1758. print("规格:", specifications)
  1759. # 如果品规中包含到期则需要再次的正则处理
  1760. if '到期' in specifications:
  1761. specifications = self.extract_specification(specifications)
  1762. # print('完整药名:', drugs_name + specifications)
  1763. return drugs_name, specifications
  1764. else:
  1765. print("没有匹配到预期格式")
  1766. drugs_name = title
  1767. specifications = ''
  1768. return drugs_name, specifications
  1769. # 用 safe_exec 包装内部逻辑,确保验证码阻塞
  1770. return self.safe_exec(_inner)
  1771. def enter_shop(self):
  1772. """
  1773. 进店,方便提取资质环境
  1774. :return:
  1775. """
  1776. self.d.xpath('//*[@text="店铺"]').click()
  1777. time.sleep(0.7)
  1778. def enter_shoper(self):
  1779. """
  1780. 进入商家
  1781. :return:
  1782. """
  1783. is_shoper_exists = 0
  1784. for i in range(5):
  1785. if self.d.xpath('//*[@text="商家"]').exists:
  1786. print(f'第{i}次商家存在')
  1787. is_shoper_exists = 1
  1788. break
  1789. else:
  1790. print(f'第{i}次商家不存在')
  1791. time.sleep(0.5)
  1792. if is_shoper_exists == 1:
  1793. self.d.xpath('//*[@text="商家"]').click()
  1794. time.sleep(1)
  1795. return True
  1796. else:
  1797. return False
  1798. # 点击查看商家资质
  1799. def scan_shoper_license(self):
  1800. exist_shoper = 0
  1801. for i in range(6):
  1802. if self.d.xpath('//*[@text="查看商家资质"]').exists:
  1803. print(f'第{i}次查看商家资质存在')
  1804. exist_shoper = 1
  1805. break
  1806. else:
  1807. print(f'第{i}次查看商家资质不存在')
  1808. if exist_shoper == 1:
  1809. self.d.xpath('//*[@text="查看商家资质"]').click()
  1810. time.sleep(0.5)
  1811. else:
  1812. self.swipe_back(1)
  1813. # 验证店铺信息是否在数据库中已存在
  1814. def shop_is_exists_database(self, shop, platform=None):
  1815. platform = str(platform or self.platform or '4').strip()
  1816. query_sql = f"""
  1817. SELECT 1 FROM {self.shop_table_name}
  1818. WHERE shop = %s
  1819. AND platform = %s
  1820. LIMIT 1
  1821. """
  1822. return self.query_exists(query_sql, (shop, platform), "店铺查重")
  1823. def query_one(self, sql, params, error_desc):
  1824. conn = None
  1825. try:
  1826. conn = get_mysql()
  1827. with conn.cursor() as cur:
  1828. cur.execute(sql, params)
  1829. row = cur.fetchone()
  1830. if not row:
  1831. return None
  1832. columns = [item[0] for item in cur.description]
  1833. return dict(zip(columns, row))
  1834. except Exception as e:
  1835. print(f"{error_desc}错误: {str(e)}")
  1836. return None
  1837. finally:
  1838. if conn:
  1839. conn.close()
  1840. def get_shop_info_from_database(self, shop, platform=None):
  1841. platform = str(platform or self.platform or '4').strip()
  1842. query_sql = f"""
  1843. SELECT shop, contact_address, qualification_number, business_license_company,
  1844. business_license_address, province, city
  1845. FROM {self.shop_table_name}
  1846. WHERE shop = %s
  1847. AND platform = %s
  1848. ORDER BY scrape_date DESC
  1849. LIMIT 1
  1850. """
  1851. return self.query_one(query_sql, (shop, platform), "查询店铺信息")
  1852. def wait_for_ready(self, monitor, timeout=86400):
  1853. """进入每一页前都先等验证码"""
  1854. if monitor is None:
  1855. return
  1856. start = time.time()
  1857. while monitor.pausing.is_set() and time.time() - start < timeout:
  1858. time.sleep(1)
  1859. def _wait_xpath_exists(self, xpath, timeout=25, interval=0.5):
  1860. deadline = time.time() + timeout
  1861. while time.time() < deadline:
  1862. self.wait_for_ready(getattr(self, "monitor", None))
  1863. try:
  1864. if self.d.xpath(xpath).exists:
  1865. return True
  1866. except Exception:
  1867. pass
  1868. time.sleep(interval)
  1869. return False
  1870. def _click_xpath_when_ready(self, xpath, action_desc, timeout=25, sleep_after=None):
  1871. if not self._wait_xpath_exists(xpath, timeout=timeout):
  1872. raise RuntimeError(f"{action_desc}失败,未找到元素: {xpath}")
  1873. self.safe_exec(lambda: self.d.xpath(xpath).click())
  1874. if sleep_after is None:
  1875. sleep_after = self.get_sleep_time()
  1876. if sleep_after > 0:
  1877. time.sleep(sleep_after)
  1878. def safe_exec(self, func, *args, **kwargs):
  1879. """
  1880. 万能安全壳:执行 func 前检查验证码,
  1881. 若监控线程已置位 pausing,则一直阻塞直到放行。
  1882. """
  1883. self.wait_for_ready(getattr(self, "monitor", None))
  1884. max_retries = 3
  1885. for attempt in range(max_retries):
  1886. try:
  1887. result = func(*args, **kwargs)
  1888. # 若执行过程中触发验证码,返回前继续阻塞直到监控放行。
  1889. self.wait_for_ready(getattr(self, "monitor", None))
  1890. return result
  1891. except http.client.RemoteDisconnected as e:
  1892. self.loggerMT.error(f"连接断开 (尝试 {attempt + 1}/{max_retries}): {e}")
  1893. if attempt == max_retries - 1:
  1894. raise # 最后一次失败,向上抛出
  1895. # 尝试重连
  1896. if self.reconnect_device():
  1897. self.loggerMT.info("重连成功,准备重试...")
  1898. time.sleep(2) # 等待设备稳定
  1899. continue
  1900. else:
  1901. self.loggerMT.error("重连失败,无法继续")
  1902. raise
  1903. except Exception as e:
  1904. # 其他异常直接抛出
  1905. raise
  1906. def get_next_data(self, data, target):
  1907. for i, item in enumerate(data):
  1908. if item['words'] == target:
  1909. if i + 1 < len(data):
  1910. return data[i + 1]['words']
  1911. return None
  1912. def delete_instruction_screenshot(self, screenshot_path):
  1913. # 删除截图文件
  1914. try:
  1915. os.remove(screenshot_path)
  1916. print(f"截图文件已删除:{screenshot_path}")
  1917. except FileNotFoundError:
  1918. print(f"文件未找到,无法删除:{screenshot_path}")
  1919. except Exception as e:
  1920. print(f"删除文件时出错:{e}")
  1921. def get_instructions_data(self, capture_only=False):
  1922. """
  1923. 确定有说明书之后,提取所有的说明书数据
  1924. :return:
  1925. """
  1926. self.d.xpath('//*[@text="说明"]').click()
  1927. time.sleep(0.3)
  1928. if self.d.xpath('//*[@text="查看详细说明"]').exists:
  1929. self.d.xpath('//*[@text="查看详细说明"]').click()
  1930. else:
  1931. view_all_xpath = self.find_xpath_with_swipes(
  1932. ['//*[@text="查看全部"]'],
  1933. swipe_direction='down',
  1934. swipe_scale=0.3,
  1935. max_swipes=3,
  1936. found_desc='查看全部'
  1937. )
  1938. if view_all_xpath:
  1939. self.d.xpath(view_all_xpath).click()
  1940. else:
  1941. res_data = {
  1942. "有效期": '',
  1943. "生产单位": '',
  1944. "批准文号": ''
  1945. }
  1946. self.loggerMT.info('获取到的说明书信息为空。')
  1947. return res_data
  1948. for ii in range(3):
  1949. if self.d.xpath('//*[@text="查看更多"]').exists:
  1950. self.d.xpath('//*[@text="查看更多"]').click()
  1951. time.sleep(0.3)
  1952. break
  1953. else:
  1954. if self.is_high_res:
  1955. self.d.swipe(200, 2000, 200, 300, 0.3)
  1956. else:
  1957. self.d.swipe(200, 1000, 200, 300, 0.3)
  1958. for iii in range(3):
  1959. if self.d.xpath('//*[@text="生产单位"]').exists and self.d.xpath('//*[@text="批准文号"]').exists:
  1960. break
  1961. else:
  1962. if self.is_high_res:
  1963. self.d.swipe(200, 2000, 200, 300, 0.3)
  1964. else:
  1965. self.d.swipe(200, 1300, 200, 300, 0.3)
  1966. # self.d.swipe_ext("up", scale=0.3)
  1967. instruction_path = self.screenshot_instruction()
  1968. self.swipe_back(1)
  1969. if capture_only:
  1970. return {"screenshot_path": instruction_path}
  1971. ocr_res = self.get_ocr_res_image(instruction_path)
  1972. if ocr_res:
  1973. # 获取有效期的下一个数据
  1974. validity = self.get_next_data(ocr_res, '有效期')
  1975. # 获取批准文号的下一个数据
  1976. approval_number = self.get_next_data(ocr_res, '批准文号')
  1977. # 获取生产单位的下一个数据
  1978. manufacturer = self.get_next_data(ocr_res, '生产单位')
  1979. else:
  1980. validity = ''
  1981. approval_number = ''
  1982. manufacturer = ''
  1983. res_data = {
  1984. "有效期": validity,
  1985. "生产单位": manufacturer,
  1986. "批准文号": approval_number
  1987. }
  1988. print(f"res_data={res_data}")
  1989. self.delete_instruction_screenshot(instruction_path)
  1990. return res_data
  1991. def has_instructions(self):
  1992. """
  1993. 是否有说明书
  1994. :return:
  1995. """
  1996. # 没有说明书的无法采集具体数据
  1997. return bool(self.find_xpath_with_swipes(
  1998. ['//*[@text="说明"]'],
  1999. swipe_direction='down',
  2000. swipe_scale=0.3,
  2001. max_swipes=4,
  2002. found_desc='说明'
  2003. ))
  2004. def has_shop(self):
  2005. """
  2006. 是否有进店按钮
  2007. :return:
  2008. """
  2009. is_has_enter_shop = self.d.xpath('//*[@text="进店"]').exists
  2010. return is_has_enter_shop
  2011. def get_license_info_capture(self):
  2012. self.enter_shop()
  2013. result = self.enter_shoper()
  2014. if result is False:
  2015. return {
  2016. 'need_save': False,
  2017. 'need_back': True,
  2018. 'contact_address': '',
  2019. 'qualification_number': '',
  2020. 'business_license_image_path': ''
  2021. }
  2022. for _ in range(5):
  2023. if self.d.xpath('//*[@text="查看商家资质"]').exists:
  2024. break
  2025. time.sleep(0.5)
  2026. contact_address = self.get_shop_address() or ''
  2027. self.scan_shoper_license()
  2028. time.sleep(3)
  2029. qualification_number = self.get_qualification_number() or ''
  2030. business_license_image_path = ''
  2031. if qualification_number:
  2032. self.d.click(0.603, 0.27)
  2033. time.sleep(1.5)
  2034. captured = self.screenshot_the_business_license(qualification_number)
  2035. business_license_image_path = str(captured) if captured else ''
  2036. return {
  2037. 'need_save': True,
  2038. 'need_back': True,
  2039. 'contact_address': contact_address,
  2040. 'qualification_number': qualification_number,
  2041. 'business_license_image_path': business_license_image_path
  2042. }
  2043. def distinct_target(self):
  2044. list_page_xpaths = [
  2045. '//*[@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]',
  2046. '//*[@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]',
  2047. '//*[@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()]',
  2048. '//*[@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()]',
  2049. '//*[@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()]',
  2050. '//*[@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()]'
  2051. ]
  2052. exists_tasks = {
  2053. f'list_xpath_{idx}': (lambda xp=xp: self.d.xpath(xp).exists)
  2054. for idx, xp in enumerate(list_page_xpaths)
  2055. }
  2056. exists_results = self.run_parallel_tasks(exists_tasks)
  2057. result = any(bool(v) for v in exists_results.values())
  2058. if result == False:
  2059. print("---检测没有回到列表页---")
  2060. return result
  2061. # return is_position
  2062. def _target_flow_xpaths(self):
  2063. return {
  2064. "medical_entry": '//*[@content-desc="看病买药"]',
  2065. "home_search_entry": '//*[@resource-id="com.sankuai.meituan:id/vf_search_carousel_text"]',
  2066. "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]',
  2067. "search_button": '//*[@text="搜索"]',
  2068. }
  2069. def _detect_target_flow_stage(self):
  2070. xpaths = self._target_flow_xpaths()
  2071. if self.distinct_target():
  2072. return "list_page"
  2073. exists_tasks = {
  2074. key: (lambda xp=xp: self.d.xpath(xp).exists)
  2075. for key, xp in xpaths.items()
  2076. }
  2077. exists_results = self.run_parallel_tasks(exists_tasks)
  2078. if exists_results.get("search_button"):
  2079. return "search_input"
  2080. if exists_results.get("search_input"):
  2081. return "search_input"
  2082. if exists_results.get("home_search_entry"):
  2083. return "home_search_entry"
  2084. if exists_results.get("medical_entry"):
  2085. return "medical_entry"
  2086. return None
  2087. def _wait_until_list_page(self, timeout=15, interval=0.8):
  2088. deadline = time.time() + timeout
  2089. while time.time() < deadline:
  2090. if self.distinct_target():
  2091. return True
  2092. time.sleep(interval)
  2093. return False
  2094. def _recover_to_list_page_from_target_flow(self):
  2095. stage = self._detect_target_flow_stage()
  2096. if stage is None:
  2097. return False
  2098. if stage == "list_page":
  2099. return True
  2100. xpaths = self._target_flow_xpaths()
  2101. print(f"检测到已退回入口流程页,当前阶段: {stage},开始顺序恢复到列表页")
  2102. if stage == "medical_entry":
  2103. self._click_xpath_when_ready(
  2104. xpaths["medical_entry"],
  2105. "进入看病买药页",
  2106. timeout=20
  2107. )
  2108. stage = "home_search_entry"
  2109. if stage == "home_search_entry":
  2110. self._click_xpath_when_ready(
  2111. xpaths["home_search_entry"],
  2112. "点击首页搜索入口",
  2113. timeout=30
  2114. )
  2115. stage = "search_input"
  2116. if stage == "search_input":
  2117. self._click_xpath_when_ready(
  2118. xpaths["search_input"],
  2119. "点击搜索输入框",
  2120. timeout=20,
  2121. sleep_after=0.5
  2122. )
  2123. self.safe_exec(lambda: self.d.send_keys(self.search_key, clear=True))
  2124. time.sleep(0.5)
  2125. self._click_xpath_when_ready(
  2126. xpaths["search_button"],
  2127. "点击搜索按钮",
  2128. timeout=20
  2129. )
  2130. self.safe_exec(self.click_express_send)
  2131. time.sleep(0.5)
  2132. self.wr_re("读", self.device_id)
  2133. if self._wait_until_list_page(timeout=18, interval=0.8):
  2134. print("已从入口流程页恢复到列表页")
  2135. return True
  2136. print("入口流程页恢复后仍未到列表页")
  2137. return False
  2138. def enter_target_page(self):
  2139. self._click_xpath_when_ready(
  2140. '//*[@content-desc="看病买药"]',
  2141. "进入看病买药页",
  2142. timeout=20
  2143. )
  2144. self._click_xpath_when_ready(
  2145. '//*[@resource-id="com.sankuai.meituan:id/vf_search_carousel_text"]',
  2146. "点击首页搜索入口",
  2147. timeout=30
  2148. )
  2149. search_input_xpaths = [
  2150. '//*[@resource-id="com.sankuai.meituan:id/dynamic_hint_layout"]',
  2151. '//*[@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]',
  2152. ]
  2153. for search_input_xpath in search_input_xpaths:
  2154. if self._wait_xpath_exists(search_input_xpath, timeout=20):
  2155. self._click_xpath_when_ready(
  2156. search_input_xpath,
  2157. "点击搜索输入框",
  2158. timeout=20,
  2159. sleep_after=0.5
  2160. )
  2161. break
  2162. else:
  2163. raise RuntimeError(f"点击搜索输入框失败,未找到元素: {search_input_xpaths}")
  2164. self.safe_exec(lambda: self.d.send_keys(self.search_key, clear=True))
  2165. time.sleep(0.5)
  2166. self._click_xpath_when_ready(
  2167. '//*[@text="搜索"]',
  2168. "点击搜索按钮",
  2169. timeout=20
  2170. )
  2171. self.safe_exec(self.click_express_send)
  2172. time.sleep(0.5)
  2173. self.wr_re("读", self.device_id)
  2174. time.sleep(2)
  2175. def click_express_send(self):
  2176. slide_xpaths = [
  2177. '//*[@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]',
  2178. '//*[@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]',
  2179. '//*[@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]',
  2180. '//*[@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]',
  2181. ]
  2182. for i in range(1, 3):
  2183. self.wait_for_ready(getattr(self, "monitor", None))
  2184. matched_slide_xpath = self.get_first_existing_xpath(slide_xpaths)
  2185. if not matched_slide_xpath:
  2186. time.sleep(self.get_sleep_time())
  2187. continue
  2188. bounds = self.d.xpath(matched_slide_xpath).info['bounds']
  2189. top = bounds['top']
  2190. bottom = bounds['bottom']
  2191. print(f'top={top}')
  2192. print(f'bottom={bottom}')
  2193. y = (top + bottom) // 2
  2194. print(f'y={y}')
  2195. self.loggerMT.info(f'开始滑动{i}')
  2196. self.safe_exec(lambda: self.d.swipe(500, y, 100, y, 0.5))
  2197. time.sleep(self.get_sleep_time())
  2198. break
  2199. express_send_xpaths = [
  2200. '//*[@text="快递送"]',
  2201. '//*[@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()]',
  2202. '//*[@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()]',
  2203. '//*[@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()]',
  2204. '//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]',
  2205. '//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]',
  2206. '//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]',
  2207. ]
  2208. self.click_candidate_xpaths(
  2209. express_send_xpaths,
  2210. action_desc="点击快递送",
  2211. max_retries=5,
  2212. sleep_after=self.get_sleep_time(),
  2213. )
  2214. def get_clipboard(self):
  2215. time.sleep(0.5)
  2216. clipboard_content = self.d.clipboard
  2217. if clipboard_content is None:
  2218. return ''
  2219. return clipboard_content.strip()
  2220. def get_product_link(self):
  2221. try:
  2222. product_link = ''
  2223. self.safe_exec(self.d.xpath(
  2224. '//android.widget.ScrollView/android.view.ViewGroup[1]/android.view.ViewGroup[1]/android.view.ViewGroup[1]').click)
  2225. if self._check_request_error_after_click():
  2226. print('进入商品链接分享失败,目前没测试原因')
  2227. self.back_to_list_page()
  2228. return product_link
  2229. max_retry = 5 # 最多尝试次数
  2230. for idx in range(1, max_retry + 1):
  2231. time.sleep(random.uniform(0.8, 1))
  2232. x = int(random.uniform(0.2, 0.7) * 720)
  2233. y = int(random.uniform(0.4, 0.7) * 1640)
  2234. self.d.touch.down(x, y)
  2235. time.sleep(random.uniform(0.45, 0.65))
  2236. self.d.touch.up(x, y)
  2237. if self.d(resourceId="com.sankuai.meituan:id/share_title").exists:
  2238. time.sleep(1.5)
  2239. save_png = str(self.device_id) + '.png'
  2240. self.d.screenshot(save_png)
  2241. product_link = decode_qr(save_png)
  2242. # 清洗:从元组中提取纯链接
  2243. if isinstance(product_link, (tuple, list)):
  2244. product_link = str(product_link[0]) if product_link else ''
  2245. else:
  2246. product_link = str(product_link or '')
  2247. # 只要合法的美团二维码链接
  2248. if not product_link.startswith('https://'):
  2249. product_link = ''
  2250. print(f'{idx}-商品链接:{product_link}')
  2251. self.loggerMT.info(f'{idx}-商品链接:{product_link}')
  2252. break
  2253. if not product_link and idx < max_retry:
  2254. time.sleep(0.1) # 最后一次不需要再等待
  2255. try:
  2256. if product_link:
  2257. os.makedirs('save_png', exist_ok=True)
  2258. os.makedirs('error', exist_ok=True)
  2259. match = re.search(r'[?&]scPid=([^&\s]+)', product_link)
  2260. if match:
  2261. code = match.group(1)
  2262. self.d.screenshot('save_png/'+code+'.png')
  2263. else:
  2264. self.d.screenshot('error/'+str(time.time())[-7:]+'.png')
  2265. except Exception as e:
  2266. print(e)
  2267. if product_link == '':
  2268. self.swipe_back(1)
  2269. else:
  2270. self.swipe_back(2)
  2271. return product_link
  2272. except Exception as e:
  2273. raise RuntimeError(f"get_product_link 失败: {e}")
  2274. def run_parallel_tasks(self, task_map):
  2275. """
  2276. 并行执行相互独立的只读任务。
  2277. 任务本身不能包含点击、滑动、返回等会改变页面状态的操作。
  2278. """
  2279. if not task_map:
  2280. return {}
  2281. results = {}
  2282. with ThreadPoolExecutor(max_workers=len(task_map)) as executor:
  2283. future_map = {
  2284. task_name: executor.submit(self.safe_exec, task_func)
  2285. for task_name, task_func in task_map.items()
  2286. }
  2287. for task_name, future in future_map.items():
  2288. try:
  2289. results[task_name] = future.result()
  2290. except Exception as e:
  2291. print(f'并行采集任务 {task_name} 执行失败: {e}')
  2292. results[task_name] = None
  2293. return results
  2294. def _cleanup_post_process_futures(self):
  2295. with self.post_process_lock:
  2296. self.post_process_futures = [f for f in self.post_process_futures if not f.done()]
  2297. def _wait_for_post_task_slot(self):
  2298. while True:
  2299. self._cleanup_post_process_futures()
  2300. with self.post_process_lock:
  2301. pending_count = len(self.post_process_futures)
  2302. futures_snapshot = list(self.post_process_futures)
  2303. if pending_count < self.max_pending_post_tasks:
  2304. return
  2305. if not futures_snapshot:
  2306. return
  2307. wait(futures_snapshot, timeout=5, return_when=FIRST_COMPLETED)
  2308. def _submit_post_process_task(self, save_data, instruction_screenshot_path='', shop_payload=None):
  2309. self._cleanup_post_process_futures()
  2310. payload = dict(save_data)
  2311. shop_payload_copy = dict(shop_payload) if isinstance(shop_payload, dict) else None
  2312. future = self.post_process_executor.submit(
  2313. self._async_finalize_and_store_data,
  2314. payload,
  2315. instruction_screenshot_path,
  2316. shop_payload_copy
  2317. )
  2318. with self.post_process_lock:
  2319. self.post_process_futures.append(future)
  2320. def _wait_post_process_tasks(self):
  2321. with self.post_process_lock:
  2322. futures_snapshot = list(self.post_process_futures)
  2323. for future in futures_snapshot:
  2324. try:
  2325. future.result()
  2326. except Exception as e:
  2327. print(f'后台任务异常: {e}')
  2328. self._cleanup_post_process_futures()
  2329. def _shutdown_post_process_executor(self):
  2330. try:
  2331. self.post_process_executor.shutdown(wait=True)
  2332. except Exception as e:
  2333. print(f'关闭后台线程池异常: {e}')
  2334. def _async_finalize_and_store_data(self, save_data, instruction_screenshot_path='', shop_payload=None):
  2335. final_data = dict(save_data)
  2336. try:
  2337. if instruction_screenshot_path and os.path.exists(instruction_screenshot_path):
  2338. ocr_res = self.get_ocr_res_image(instruction_screenshot_path)
  2339. if ocr_res:
  2340. validity = self.get_next_data(ocr_res, '有效期')
  2341. manufacturer = self.get_next_data(ocr_res, '生产单位')
  2342. approval_number = self.get_next_data(ocr_res, '批准文号')
  2343. if validity:
  2344. final_data['expiry_date'] = str(validity).strip('。')
  2345. if manufacturer:
  2346. final_data['manufacturer'] = str(manufacturer).strip('。')
  2347. if approval_number:
  2348. final_data['approval_number'] = str(approval_number).strip('。')
  2349. except Exception as e:
  2350. print(f'后台处理说明书OCR异常: {e}')
  2351. finally:
  2352. if instruction_screenshot_path:
  2353. self.delete_instruction_screenshot(instruction_screenshot_path)
  2354. try:
  2355. if isinstance(shop_payload, dict) and shop_payload.get("need_save"):
  2356. company = str(shop_payload.get('business_license_company') or '').strip()
  2357. address = str(shop_payload.get('business_license_address') or '').strip()
  2358. contact_address = str(shop_payload.get('contact_address') or address).strip()
  2359. province = str(shop_payload.get('province') or '').strip()
  2360. city = str(shop_payload.get('city') or '').strip()
  2361. biz_img = shop_payload.get("business_license_image_path", "")
  2362. ocr_res = None
  2363. should_ocr = (not company)
  2364. if ENABLE_SHOP_DEBUG:
  2365. print(
  2366. f"[SHOP-DEBUG] async shop payload: shop={shop_payload.get('shop', '')}, "
  2367. f"should_ocr={should_ocr}, company={company}, contact_address={contact_address}, "
  2368. f"biz_address={address}, province={province}, city={city}, biz_img_exists={bool(biz_img and os.path.exists(biz_img))}"
  2369. )
  2370. if should_ocr and biz_img and os.path.exists(biz_img):
  2371. ocr_res = self.get_ocr_res(biz_img)
  2372. if ocr_res:
  2373. company = ocr_res.get('单位名称', '') if isinstance(ocr_res, dict) else ''
  2374. address = ocr_res.get('地址', '') if isinstance(ocr_res, dict) else ''
  2375. contact_address = str(address or '').strip()
  2376. if ENABLE_SHOP_DEBUG:
  2377. print(f"[SHOP-DEBUG] OCR结果: company={company}, biz_address={address}")
  2378. if biz_img and os.path.exists(biz_img):
  2379. self.write_ocr_result_sidecar(
  2380. biz_img,
  2381. ocr_res if should_ocr else {},
  2382. source="business_license",
  2383. extra={
  2384. "shop": shop_payload.get('shop', ''),
  2385. "qualification_number": shop_payload.get('qualification_number', ''),
  2386. "should_ocr": should_ocr,
  2387. "attempted": bool(should_ocr),
  2388. "parsed_company": company,
  2389. "parsed_address": address,
  2390. "contact_address": contact_address
  2391. }
  2392. )
  2393. if not address and contact_address:
  2394. address = contact_address
  2395. if not contact_address and address:
  2396. contact_address = address
  2397. if (not province) or (not city):
  2398. match = self.area_service.search_area(address or contact_address)
  2399. if match:
  2400. province = province or match.province
  2401. city = city or match.city
  2402. if ENABLE_SHOP_DEBUG:
  2403. print(
  2404. f"[SHOP-DEBUG] region推断后: province={province}, city={city}, by_address={address or contact_address}")
  2405. save_shop_data = {
  2406. 'shop': shop_payload.get('shop', ''),
  2407. 'contact_address': contact_address,
  2408. 'qualification_number': shop_payload.get('qualification_number', ''),
  2409. 'scrape_date': shop_payload.get('scrape_date', self.get_current_date()),
  2410. 'business_license_company': company,
  2411. 'business_license_address': address,
  2412. 'platform': str(shop_payload.get('platform') or self.platform),
  2413. 'province': province,
  2414. 'city': city,
  2415. 'create_time': time.strftime('%Y-%m-%d %H:%M:%S'),
  2416. 'update_time': time.strftime('%Y-%m-%d %H:%M:%S')
  2417. }
  2418. self.save_shop_info_to_database(save_shop_data)
  2419. # 用 area_service 获取 province_id / city_id
  2420. match = self.area_service.search_area(province + city) if (province or city) else None
  2421. derived_province_id = match.province_id if match else 0
  2422. derived_city_id = match.city_id if match else 0
  2423. if ENABLE_SHOP_DEBUG:
  2424. print(f"[SHOP-DEBUG] derived ids: province_id={derived_province_id}, city_id={derived_city_id}")
  2425. if not final_data.get('company_name'):
  2426. final_data['company_name'] = company
  2427. if not final_data.get('qualification_number'):
  2428. final_data['qualification_number'] = str(shop_payload.get('qualification_number', '') or '').strip()
  2429. if province:
  2430. final_data['province_name'] = province
  2431. if city:
  2432. final_data['city_name'] = city
  2433. if derived_province_id:
  2434. final_data['province_id'] = derived_province_id
  2435. if derived_city_id:
  2436. final_data['city_id'] = derived_city_id
  2437. if ENABLE_SHOP_DEBUG:
  2438. print(
  2439. f"[SHOP-DEBUG] final_data回填: company_name={final_data.get('company_name')}, "
  2440. f"qualification_number={final_data.get('qualification_number')}, "
  2441. f"province_name={final_data.get('province_name')}, city_name={final_data.get('city_name')}, "
  2442. f"province_id={final_data.get('province_id')}, city_id={final_data.get('city_id')}"
  2443. )
  2444. except Exception as e:
  2445. print(f'后台处理店铺OCR异常: {e}')
  2446. print(final_data)
  2447. saved = self.save_to_database(final_data)
  2448. if saved:
  2449. current_count = self.mark_collected_data_saved()
  2450. if self.count is not None:
  2451. print(f"当前已成功采集 {current_count}/{self.count} 条")
  2452. def get_available_xpaths(self, xpaths):
  2453. check_tasks = {
  2454. f'xpath_{idx}': (lambda xp=xp: self.d.xpath(xp).exists)
  2455. for idx, xp in enumerate(xpaths)
  2456. }
  2457. exists_results = self.run_parallel_tasks(check_tasks)
  2458. return [
  2459. xpath for idx, xpath in enumerate(xpaths)
  2460. if exists_results.get(f'xpath_{idx}')
  2461. ]
  2462. def get_first_existing_xpath(self, xpaths):
  2463. available_xpaths = self.get_available_xpaths(xpaths)
  2464. if not available_xpaths:
  2465. return None
  2466. return available_xpaths[0]
  2467. def get_first_text_by_xpaths(self, xpaths):
  2468. text_tasks = {
  2469. f'xpath_{idx}': (lambda xp=xp: self._read_xpath_text(xp))
  2470. for idx, xp in enumerate(xpaths)
  2471. }
  2472. text_results = self.run_parallel_tasks(text_tasks)
  2473. for idx, _ in enumerate(xpaths):
  2474. text = text_results.get(f'xpath_{idx}')
  2475. if text:
  2476. return text
  2477. return ''
  2478. def get_first_texts_by_xpath_groups(self, xpath_groups):
  2479. tasks = {}
  2480. group_keys = {}
  2481. for group_name, xpaths in xpath_groups.items():
  2482. group_keys[group_name] = []
  2483. for idx, xpath in enumerate(xpaths):
  2484. task_name = f'{group_name}_{idx}'
  2485. group_keys[group_name].append(task_name)
  2486. tasks[task_name] = (lambda xp=xpath: self._read_xpath_text(xp))
  2487. text_results = self.run_parallel_tasks(tasks)
  2488. grouped_results = {}
  2489. for group_name, task_names in group_keys.items():
  2490. grouped_results[group_name] = ''
  2491. for task_name in task_names:
  2492. text = text_results.get(task_name)
  2493. if text:
  2494. grouped_results[group_name] = text
  2495. break
  2496. return grouped_results
  2497. def _read_xpath_text(self, xpath):
  2498. selector = self.d.xpath(xpath)
  2499. if not selector.exists:
  2500. return ''
  2501. try:
  2502. text = selector.text
  2503. return text.strip() if isinstance(text, str) else text
  2504. except Exception:
  2505. return ''
  2506. def click_candidate_xpaths(self, xpaths, action_desc, max_retries=1, sleep_after=0):
  2507. for attempt in range(1, max_retries + 1):
  2508. available_xpaths = self.get_available_xpaths(xpaths)
  2509. if not available_xpaths:
  2510. print(f'{action_desc}失败,第{attempt}次没有匹配到可点击的xpath')
  2511. time.sleep(self.get_sleep_time())
  2512. continue
  2513. rotate_offset = (attempt - 1) % len(available_xpaths)
  2514. candidate_xpaths = available_xpaths[rotate_offset:] + available_xpaths[:rotate_offset]
  2515. for xpath in candidate_xpaths:
  2516. try:
  2517. self.safe_exec(lambda xp=xpath: self.d.xpath(xp).click())
  2518. print(f'{action_desc}成功')
  2519. if sleep_after:
  2520. time.sleep(sleep_after)
  2521. return xpath
  2522. except Exception as e:
  2523. print(f'{action_desc}点击异常: {e}')
  2524. time.sleep(0.5)
  2525. return None
  2526. def find_xpath_with_swipes(self, xpaths, swipe_direction='down', swipe_scale=0.3, max_swipes=8, found_desc=''):
  2527. for idx in range(max_swipes):
  2528. matched_xpath = self.get_first_existing_xpath(xpaths)
  2529. if matched_xpath:
  2530. if found_desc:
  2531. print(f'第{idx}次找到{found_desc}')
  2532. return matched_xpath
  2533. self.d.swipe_ext(swipe_direction, swipe_scale)
  2534. matched_xpath = self.get_first_existing_xpath(xpaths)
  2535. if matched_xpath and found_desc:
  2536. print(f'第{max_swipes}次找到{found_desc}')
  2537. return matched_xpath
  2538. def _collect_detail_core_data(self, prefetched, ctx):
  2539. """步骤1: 采集详情页核心字段(标题、价格、销量、自营状态)"""
  2540. prefetched = prefetched if isinstance(prefetched, dict) else {}
  2541. prefetched_product = str(prefetched.get("product") or "").strip()
  2542. prefetched_specifications = str(prefetched.get("specifications") or "").strip()
  2543. prefetched_shop = str(prefetched.get("shop") or "").strip()
  2544. detail_tasks = {
  2545. "sales_num": self.drug_sale_num,
  2546. "is_self_operated": lambda: self.d.xpath('//*[@text="自营"]').exists,
  2547. "min_price": self.drug_price,
  2548. }
  2549. if not prefetched_product:
  2550. detail_tasks["title_info"] = self.get_title
  2551. detail_data = self.run_parallel_tasks(detail_tasks)
  2552. if not prefetched_shop:
  2553. detail_data["shop_inline"] = self.safe_exec(self.get_shop_name_from_current_page)
  2554. if prefetched_product:
  2555. product = prefetched_product
  2556. specifications = prefetched_specifications
  2557. else:
  2558. title_info = detail_data.get("title_info")
  2559. if isinstance(title_info, (list, tuple)) and len(title_info) >= 2:
  2560. product, specifications = title_info[0], title_info[1]
  2561. else:
  2562. product, specifications = "", ""
  2563. if not product:
  2564. self.swipe_back(1)
  2565. return False
  2566. min_price = detail_data.get("min_price")
  2567. if min_price in (None, ""):
  2568. print("详情页未获取到价格,返回列表页采集下一条")
  2569. return False
  2570. if self.collect_range:
  2571. range_start = self.collect_range["start"]
  2572. range_end = self.collect_range["end"]
  2573. if not (range_start <= min_price <= range_end):
  2574. print(f"detail price {min_price} not in range {range_start}-{range_end}, skip")
  2575. return False
  2576. ctx.update({
  2577. "detail_data": detail_data,
  2578. "product": product,
  2579. "specifications": specifications,
  2580. "min_price": min_price,
  2581. "shop": prefetched_shop or (detail_data.get("shop_inline") or "").strip(),
  2582. "sales_num": detail_data.get("sales_num"),
  2583. "scrape_date": self.get_current_date(),
  2584. "product_link": "",
  2585. "shop_async_payload": None,
  2586. "shop_db_info": None,
  2587. "need_collect_shop_ocr": False,
  2588. "instruction_screenshot_path": "",
  2589. "manufacture_date": "",
  2590. "expiry_date": "",
  2591. "manufacturer": "",
  2592. "approval_number": "",
  2593. })
  2594. return True
  2595. def _is_official_shop(self, shop="", detail_data=None):
  2596. shop = str(shop or "").strip()
  2597. detail_data = detail_data if isinstance(detail_data, dict) else {}
  2598. return bool(
  2599. detail_data.get("is_self_operated")
  2600. or ("美团官方" in shop)
  2601. or ("美团自营" in shop)
  2602. )
  2603. def _handle_detail_shop(self, ctx):
  2604. """步骤2: 处理店铺、店铺数据库信息与商品链接"""
  2605. detail_data = ctx.get("detail_data") or {}
  2606. product = ctx["product"]
  2607. min_price = ctx["min_price"]
  2608. scrape_date = ctx["scrape_date"]
  2609. ctx["platform_item_id"] = ctx["shop"] + product
  2610. if detail_data.get("is_self_operated"):
  2611. ctx["shop"] = "美团自营大药房(快递电商)"
  2612. ctx["platform_item_id"] = ctx["shop"] + product
  2613. self._reset_product_link_missing_counter()
  2614. return True
  2615. if not ctx["shop"]:
  2616. self.find_xpath_with_swipes(
  2617. ['//*[@text="进店"]'],
  2618. swipe_direction='up',
  2619. swipe_scale=0.3,
  2620. max_swipes=8,
  2621. found_desc='进店'
  2622. )
  2623. ctx["shop"] = (self.get_shop_name_from_current_page() or self.get_shop_name() or "").strip()
  2624. if not ctx["shop"]:
  2625. print('未获取到店铺名:开始回退')
  2626. self.back_to_list_page()
  2627. return False
  2628. ctx["platform_item_id"] = ctx["shop"] + product
  2629. db_check_tasks = {
  2630. "shop_exists": lambda: self.shop_is_exists_database(ctx["shop"], self.platform),
  2631. }
  2632. db_check_results = self.run_parallel_tasks(db_check_tasks)
  2633. shop_db_info = None
  2634. shop_is_exists = bool(db_check_results.get("shop_exists"))
  2635. if shop_is_exists:
  2636. shop_db_info = self.get_shop_info_from_database(ctx["shop"], self.platform)
  2637. company = str((shop_db_info or {}).get("business_license_company") or "").strip()
  2638. biz_address = str((shop_db_info or {}).get("business_license_address") or "").strip()
  2639. contact = str((shop_db_info or {}).get("contact_address") or "").strip()
  2640. need_collect_shop_ocr = (not shop_is_exists) or (not company)
  2641. if ENABLE_SHOP_DEBUG:
  2642. print(
  2643. f"[SHOP-DEBUG] should_collect_shop_ocr: "
  2644. f"company_empty={not bool(company)}, biz_address_empty={not bool(biz_address)}, contact_empty={not bool(contact)}"
  2645. )
  2646. print(
  2647. f"[SHOP-DEBUG] 主流程店铺判定: shop={ctx['shop']}, "
  2648. f"shop_is_exists={shop_is_exists}, need_collect_shop_ocr={need_collect_shop_ocr}, "
  2649. f"shop_db_info={shop_db_info}"
  2650. )
  2651. ctx["shop_db_info"] = shop_db_info
  2652. ctx["need_collect_shop_ocr"] = need_collect_shop_ocr
  2653. ctx["product_link"] = self._record_product_link_result(self.get_product_link())
  2654. if not ctx["product_link"]:
  2655. print("当前商品获取不到商品链接,返回列表页")
  2656. self.back_to_list_page()
  2657. return False
  2658. return True
  2659. def _collect_detail_instruction_info(self, ctx):
  2660. """步骤3: 采集说明书截图信息"""
  2661. if not self.safe_exec(self.has_instructions):
  2662. return
  2663. print('开始获取说明书信息')
  2664. try:
  2665. instructions_info = self.safe_exec(lambda: self.get_instructions_data(capture_only=True))
  2666. if isinstance(instructions_info, dict):
  2667. ctx["instruction_screenshot_path"] = instructions_info.get("screenshot_path", "")
  2668. except Exception as e:
  2669. print(f'说明书采集跳过: {e}')
  2670. def _collect_detail_shop_ocr(self, ctx):
  2671. """步骤4: 采集店铺资质 OCR"""
  2672. if self._is_official_shop(ctx.get("shop", ""), ctx.get("detail_data")):
  2673. return
  2674. print(f"已采集{self.shop_data_num}家店铺数据")
  2675. is_has_enter_shop = bool(self.safe_exec(self.has_shop))
  2676. shop = ctx["shop"]
  2677. if is_has_enter_shop and ctx["need_collect_shop_ocr"] and self.shop_data_num < 500:
  2678. license_capture = self.safe_exec(self.get_license_info_capture)
  2679. if license_capture.get("need_back"):
  2680. self.swipe_back(2)
  2681. ctx["shop_async_payload"] = {
  2682. "need_save": bool(license_capture.get("need_save")),
  2683. "shop": shop,
  2684. "contact_address": license_capture.get("contact_address", ""),
  2685. "qualification_number": license_capture.get("qualification_number", ""),
  2686. "business_license_image_path": license_capture.get("business_license_image_path", ""),
  2687. "scrape_date": ctx["scrape_date"],
  2688. "platform": str(self.platform),
  2689. }
  2690. if ENABLE_SHOP_DEBUG:
  2691. print(f"[SHOP-DEBUG] 走OCR采集分支, shop_async_payload={ctx['shop_async_payload']}")
  2692. if ctx["shop_async_payload"]["need_save"]:
  2693. self.shop_data_num += 1
  2694. else:
  2695. print('不采集店铺信息')
  2696. def _build_detail_save_data(self, ctx):
  2697. """步骤5: 组装详情页保存数据"""
  2698. shop_db_info = ctx.get("shop_db_info")
  2699. shop = ctx["shop"]
  2700. province_id, city_id, province, city = 0, 0, '', ''
  2701. # 优先从 DB 缓存取,没有则从地址中推断
  2702. addr_for_region = ''
  2703. if isinstance(shop_db_info, dict):
  2704. province = str(shop_db_info.get('province') or '').strip()
  2705. city = str(shop_db_info.get('city') or '').strip()
  2706. addr_for_region = str(
  2707. shop_db_info.get('business_license_address')
  2708. or shop_db_info.get('contact_address')
  2709. or ''
  2710. ).strip()
  2711. if (not province or not city) and addr_for_region:
  2712. match = self.area_service.search_area(addr_for_region)
  2713. if match:
  2714. province_id, city_id = match.province_id, match.city_id
  2715. province = province or match.province
  2716. city = city or match.city
  2717. elif province or city:
  2718. # DB 已有省市,只查 ID
  2719. match = self.area_service.search_area(province + city)
  2720. if match:
  2721. province_id, city_id = match.province_id, match.city_id
  2722. if ENABLE_SHOP_DEBUG:
  2723. print(
  2724. f"[SHOP-DEBUG] save_data省市计算: shop={shop}, province={province}, city={city}, "
  2725. f"province_id={province_id}, city_id={city_id}"
  2726. )
  2727. save_data = {
  2728. 'enterprise_id': self.enterprise_id,
  2729. 'platform_id': 4,
  2730. 'platform_item_id': ctx["platform_item_id"],
  2731. 'province_id': province_id,
  2732. 'city_id': city_id,
  2733. 'province_name': '',
  2734. 'city_name': '',
  2735. 'area_info': "",
  2736. 'product_brand': self.brand,
  2737. 'product_name': ctx["product"],
  2738. 'product_specs': ctx["specifications"],
  2739. 'one_box_price': 0.00,
  2740. 'manufacture_date': ctx["manufacture_date"],
  2741. 'expiry_date': ctx["expiry_date"],
  2742. 'manufacturer': ctx["manufacturer"],
  2743. 'approval_number': ctx["approval_number"],
  2744. 'is_sold_out': 0,
  2745. 'online_posting_count': 1,
  2746. 'continuous_listing_count': 1,
  2747. 'link_url': ctx["product_link"],
  2748. 'store_name': shop,
  2749. 'store_url': '',
  2750. 'shipment_province_id': 0,
  2751. 'shipment_province_name': "",
  2752. 'shipment_city_id': 0,
  2753. 'shipment_city_name': "",
  2754. 'company_name': "",
  2755. 'qualification_number': "",
  2756. 'search_name': self.search_key,
  2757. 'scrape_date': ctx["scrape_date"],
  2758. 'min_price': ctx["min_price"],
  2759. 'number': 1,
  2760. 'sales': ctx["sales_num"],
  2761. 'inventory': "",
  2762. 'snapshot_url': str(ctx.get("snapshot_url") or ""),
  2763. 'collect_equipment_account_id': self.collect_equipment_account_id,
  2764. 'collect_region_id': self.collect_region_id,
  2765. 'collect_round': self.collect_round,
  2766. 'insert_time': time.strftime('%Y-%m-%d %H:%M:%S'),
  2767. 'update_time': time.strftime('%Y-%m-%d %H:%M:%S'),
  2768. 'collect_config_info': {
  2769. "sampling_cycle": self.sampling_cycle,
  2770. "sampling_start_time": self.sampling_start_time,
  2771. "sampling_end_time": self.sampling_end_time,
  2772. },
  2773. 'task_id': self.task_id,
  2774. }
  2775. if isinstance(shop_db_info, dict):
  2776. save_data['province_name'] = province
  2777. save_data['city_name'] = city
  2778. save_data['province_id'] = province_id
  2779. save_data['city_id'] = city_id
  2780. save_data['company_name'] = str(shop_db_info.get('business_license_company') or '')
  2781. save_data['qualification_number'] = str(shop_db_info.get('qualification_number') or '')
  2782. if ENABLE_SHOP_DEBUG:
  2783. print(
  2784. f"[SHOP-DEBUG] save_data DB回填结果: company_name={save_data['company_name']}, "
  2785. f"qualification_number={save_data['qualification_number']}, "
  2786. f"province_name={save_data['province_name']}, city_name={save_data['city_name']}, "
  2787. f"province_id={save_data['province_id']}, city_id={save_data['city_id']}"
  2788. )
  2789. if isinstance(save_data.get('collect_config_info'), dict):
  2790. save_data['collect_config_info'] = json.dumps(save_data['collect_config_info'], ensure_ascii=False)
  2791. ctx["save_data"] = save_data
  2792. return save_data
  2793. def _submit_detail_record(self, ctx):
  2794. """步骤6: 提交后台处理并回到列表页"""
  2795. self._submit_post_process_task(
  2796. ctx["save_data"],
  2797. instruction_screenshot_path=ctx["instruction_screenshot_path"],
  2798. shop_payload=ctx["shop_async_payload"]
  2799. )
  2800. print(f'[{datetime.datetime.now().strftime("%H:%M:%S.%f")}] 已提交后台异步处理OCR与入库')
  2801. print(f'[{datetime.datetime.now().strftime("%H:%M:%S.%f")}] 开始回列表页')
  2802. return self.back_to_list_page()
  2803. def integrate_data(self, prefetched=None):
  2804. """
  2805. 整合详情页数据
  2806. """
  2807. ctx = {}
  2808. prefetched_title = ""
  2809. if isinstance(prefetched, dict):
  2810. prefetched_title = str(prefetched.get("product") or "").strip()
  2811. mt_screenshot = MTScreenshot(
  2812. d=self.d,
  2813. oss_config=self.oss_config,
  2814. search_key=self.search_key, # 添加这行
  2815. title_key=self.title_key,
  2816. device_id=self.device_id,
  2817. monitor=getattr(self, "monitor", None)
  2818. )
  2819. # 1. 采集详情页核心字段
  2820. if not self._collect_detail_core_data(prefetched, ctx):
  2821. return
  2822. # 2. 处理店铺、去重、店铺数据库信息与商品链接
  2823. if not self._handle_detail_shop(ctx):
  2824. return
  2825. # 3. 采集说明书截图
  2826. self._collect_detail_instruction_info(ctx)
  2827. # 4.网页快照
  2828. snapshot_url = mt_screenshot.get_oss_url(title=prefetched_title)
  2829. ctx["snapshot_url"] = str(snapshot_url or "")
  2830. # 5. 采集店铺资质 OCR
  2831. self._collect_detail_shop_ocr(ctx)
  2832. # 6. 组装保存数据
  2833. self._build_detail_save_data(ctx)
  2834. # 7. 提交后台处理并回列表页
  2835. return self._submit_detail_record(ctx)
  2836. def back_to_list_page(self):
  2837. for i in range(5):
  2838. if self.distinct_target():
  2839. return True
  2840. if i >= 3 and self._recover_to_list_page_from_target_flow():
  2841. return True
  2842. print(f'第{i}次尝试退回到列表页')
  2843. self.swipe_back(1)
  2844. time.sleep(0.5)
  2845. if self._recover_to_list_page_from_target_flow():
  2846. return True
  2847. print('页面出错,没有退回到列表页')
  2848. return False
  2849. def reset_collection_cursor(self):
  2850. self.collection_cursor["page_no"] = 1
  2851. self.collection_cursor["item_index"] = 0
  2852. def get_current_page_no(self):
  2853. return self.page + self.collection_cursor["page_no"]
  2854. def jump_to_page(self, target_page):
  2855. current_page = self.get_current_page_no()
  2856. if target_page <= current_page:
  2857. return
  2858. while current_page < target_page:
  2859. if self.d.xpath('//*[@text="已经到底啦"]').exists:
  2860. print(f"列表实际页数不足,当前停留在第{current_page}页,无法跳转到第{target_page}页")
  2861. return
  2862. print(f"跳过第{current_page}页,前往第{current_page + 1}页")
  2863. if self.is_high_res:
  2864. self.d.drag(300, 2600, 300, 400, 1)
  2865. else:
  2866. self.d.drag(300, 1400, 300, 400, 1)
  2867. time.sleep(1)
  2868. self.collection_cursor["page_no"] += 1
  2869. self.collection_cursor["item_index"] = 0
  2870. current_page = self.get_current_page_no()
  2871. def move_to_page_range_start(self):
  2872. if not self.page_range:
  2873. return
  2874. start_page = self.page_range["start"]
  2875. current_page = self.get_current_page_no()
  2876. if current_page < start_page:
  2877. self.jump_to_page(start_page)
  2878. def start_collection_app(self):
  2879. self.sort_key = 0
  2880. self.restart_app()
  2881. def open_product_list_page(self):
  2882. last_error = None
  2883. for attempt in range(1, OPEN_PRODUCT_LIST_PAGE_RETRY + 1):
  2884. try:
  2885. self.safe_exec(self.enter_target_page)
  2886. self.reset_collection_cursor()
  2887. if self.sort and self.sort_key == 0:
  2888. self.li_or_lo(self.sort)
  2889. self.move_to_page_range_start()
  2890. return
  2891. except Exception as e:
  2892. last_error = e
  2893. self.loggerMT.warning(
  2894. f"open_product_list_page 第{attempt}/{OPEN_PRODUCT_LIST_PAGE_RETRY}次失败: {e}"
  2895. )
  2896. if attempt >= OPEN_PRODUCT_LIST_PAGE_RETRY:
  2897. break
  2898. ready = self._wait_xpath_exists('//*[@content-desc="看病买药"]', timeout=6, interval=0.5)
  2899. if not ready:
  2900. record_restart_reason(
  2901. reason="open_product_list_page 入口未就绪,执行 restart_app 后重试",
  2902. source="workflow",
  2903. device_id=getattr(self, "device_id", None),
  2904. task_id=getattr(self, "task_id", None),
  2905. step="open_product_list_page",
  2906. action="restart_app",
  2907. fail_count=attempt,
  2908. retry_limit=OPEN_PRODUCT_LIST_PAGE_RETRY,
  2909. search_key=getattr(self, "search_key", None),
  2910. exc=e,
  2911. traceback_text=traceback.format_exc(),
  2912. )
  2913. self.restart_app()
  2914. time.sleep(max(2, self.get_sleep_time()))
  2915. raise RuntimeError(f"open_product_list_page 重试{OPEN_PRODUCT_LIST_PAGE_RETRY}次仍失败: {last_error}")
  2916. def handle_workflow_error(self, step_name):
  2917. action = self.workflow_error_action.get(step_name)
  2918. if action == "back_to_list_page":
  2919. if not self.back_to_list_page():
  2920. raise RuntimeError("退回列表页失败")
  2921. return "collect_single_product"
  2922. if action == "open_product_list_page":
  2923. return "open_product_list_page"
  2924. if action == "start_app":
  2925. return "start_app"
  2926. raise RuntimeError(f"未配置步骤 {step_name} 的错误处理动作")
  2927. def get_list_items(self):
  2928. for _ in range(10):
  2929. items = self.safe_exec(
  2930. self.d.xpath('//androidx.recyclerview.widget.RecyclerView/android.widget.FrameLayout').all
  2931. )
  2932. if items:
  2933. return items
  2934. time.sleep(1)
  2935. raise RuntimeError("列表页商品加载失败")
  2936. def _get_list_visible_params(self):
  2937. """根据分辨率返回列表可见区参数"""
  2938. if self.is_high_res:
  2939. return {"visible_top": 509, "visible_bottom": 2646, "target_top": 519}
  2940. else:
  2941. return {"visible_top": 304, "visible_bottom": 1475, "target_top": 314}
  2942. def _get_sorted_visible_items(self):
  2943. """获取 RecyclerView 中所有可见 item,按 top 从小到大排序"""
  2944. items = self.d.xpath(
  2945. '//androidx.recyclerview.widget.RecyclerView/android.widget.FrameLayout'
  2946. ).all()
  2947. result = []
  2948. for item in (items or []):
  2949. try:
  2950. bounds = item.info.get("bounds") or item.info.get("visibleBounds") or {}
  2951. top = int(bounds.get("top", 0))
  2952. bottom = int(bounds.get("bottom", 0))
  2953. if top >= 0 and bottom > top:
  2954. result.append((top, bottom, item))
  2955. except Exception:
  2956. continue
  2957. result.sort(key=lambda x: x[0])
  2958. return result
  2959. def _anchor_scroll_to_next_page(self):
  2960. """锚点滑动:把最后一个可见 item 滑到可见区顶部,确保翻页不漏商品"""
  2961. sorted_items = self._get_sorted_visible_items()
  2962. if len(sorted_items) < 1:
  2963. return False
  2964. params = self._get_list_visible_params()
  2965. anchor_top = sorted_items[-1][0]
  2966. target_top = params["target_top"]
  2967. scroll_distance = anchor_top - target_top
  2968. if scroll_distance <= 0:
  2969. return False
  2970. screen_width = self.d.info.get("displayWidth", 1220)
  2971. start_x = screen_width // 2
  2972. start_y = params["visible_bottom"] - 200
  2973. end_y = max(start_y - scroll_distance, 100)
  2974. self.d.drag(start_x, start_y, start_x, end_y, duration=1)
  2975. return True
  2976. def move_to_next_list_page(self):
  2977. current_page = self.get_current_page_no()
  2978. # 逐页回告:当前页采集完毕,上报进度
  2979. if self.scheduler and self.task_id:
  2980. resp = self.scheduler.post_report({
  2981. "task_id": self.task_id,
  2982. "platform": str(self.platform),
  2983. "username": self.scheduler.username,
  2984. "current_page": current_page,
  2985. "crawled_count": self.get_collected_data_count(),
  2986. "is_finished": 0,
  2987. })
  2988. if isinstance(resp, dict) and resp.get("code") == "error":
  2989. logging.warning(f"调度器要求停止: {resp.get('msg', '')}")
  2990. return False
  2991. if self.page_range and current_page >= self.page_range["end"]:
  2992. self.wr_re("写", self.device_id, self.sort, current_page)
  2993. print(f'已完成第{current_page}页采集,达到结束页{self.page_range["end"]},停止采集')
  2994. return False
  2995. if self.d.xpath('//*[@text="已经到底啦"]').exists:
  2996. return False
  2997. # 翻页前检查"加载更多"按钮,检测风控卡死
  2998. if self.d.xpath('//*[@text="加载更多"]').exists:
  2999. self._check_wind_control_stuck()
  3000. self.wr_re("写", self.device_id, self.sort, current_page)
  3001. print(f'当前第{current_page}页采集完成,开始滑动到下一页')
  3002. self._anchor_scroll_to_next_page()
  3003. time.sleep(1)
  3004. self.collection_cursor["page_no"] += 1
  3005. self.collection_cursor["item_index"] = 0
  3006. return True
  3007. def _get_list_item_snapshot(self, drug_idx, drug_one):
  3008. """步骤1: 读取列表商品的可见区域和文本快照"""
  3009. bounds = drug_one.info['bounds']
  3010. top = bounds['top']
  3011. bottom = bounds['bottom']
  3012. # print(f'当前商品bottom:{bottom}')
  3013. # print(f'当前商品top:{top}')
  3014. if self.is_high_res:
  3015. if not (509 <= top and bottom <= 2646):
  3016. return None
  3017. else:
  3018. if not (304 < top and bottom <= 1475):
  3019. return None
  3020. item_text_data = self.get_first_texts_by_xpath_groups({
  3021. "product_title": [
  3022. 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]'],
  3023. "price_str": [
  3024. 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]',
  3025. ],
  3026. "shop_name": [
  3027. f'//androidx.recyclerview.widget.RecyclerView/android.widget.FrameLayout[{drug_idx}]//*[contains(@text, "快递电商")]',
  3028. 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',
  3029. 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]',
  3030. 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]'
  3031. ],
  3032. })
  3033. return {
  3034. "drug_idx": drug_idx,
  3035. "drug_one": drug_one,
  3036. "item_text_data": item_text_data,
  3037. }
  3038. def _prepare_list_item_collect(self, item_ctx):
  3039. """步骤2: 解析列表商品信息并完成过滤/去重准备"""
  3040. drug_idx = item_ctx["drug_idx"]
  3041. item_text_data = item_ctx["item_text_data"]
  3042. print(f"这页的第几个商品:{drug_idx}")
  3043. product_title = item_text_data.get("product_title", "")
  3044. if not product_title:
  3045. print("列表当前商品名称不存在")
  3046. self.unrelated_data += 1
  3047. return "continue"
  3048. product_title = product_title[1:] if product_title.startswith('0') else product_title
  3049. print(f"列表当前商品名称:{product_title}")
  3050. if not self.is_link_useful(product_title):
  3051. print(f"is_link_useful 没通过:{product_title}")
  3052. self.unrelated_data += 1
  3053. return "continue"
  3054. self.unrelated_data = 0
  3055. price = ''
  3056. price_str = item_text_data.get("price_str", "")
  3057. print(f"列表当前商品价格:{price_str}")
  3058. if price_str:
  3059. price = float(re.search(r'[\d\.]+', price_str).group())
  3060. shop_name = item_text_data.get("shop_name", "")
  3061. print(f"列表当前商品店铺名称:{shop_name}")
  3062. if price == '' or shop_name == '':
  3063. print("列表当前商品价格或店铺名称不存在")
  3064. return "continue"
  3065. scrape_date = self.get_current_date()
  3066. item_ctx["product_title"] = product_title
  3067. item_ctx["shop_name"] = shop_name
  3068. item_ctx["prefetched_detail"] = {
  3069. "product": product_title,
  3070. "specifications": "",
  3071. "shop": shop_name,
  3072. }
  3073. return "ready"
  3074. def _reset_request_error_counter(self):
  3075. if self.request_error_count:
  3076. print(f"请求错误计数已重置,上一轮累计: {self.request_error_count}")
  3077. self.request_error_count = 0
  3078. def _reset_product_link_missing_counter(self):
  3079. if self.product_link_missing_count:
  3080. print(f"商品链接缺失计数已重置,上一轮累计: {self.product_link_missing_count}")
  3081. self.product_link_missing_count = 0
  3082. def _check_wind_control_stuck(self):
  3083. """
  3084. 风控卡死检测:
  3085. 每隔30秒检查一次 //*[@text="加载更多"] 是否存在,共检查4次。
  3086. 如果4次都存在,判定为风控卡死,抛出异常终止任务。
  3087. 如果任意一次不存在,判定为非风控,继续正常采集。
  3088. """
  3089. self.loggerMT.warning(
  3090. f"开始风控卡死检测,将检查'加载更多'按钮{self.load_more_check_rounds}次,"
  3091. f"间隔{self.load_more_check_interval}秒"
  3092. )
  3093. load_more_xpath = '//*[@text="加载更多"]'
  3094. load_more_exists_count = 0
  3095. for check_round in range(1, self.load_more_check_rounds + 1):
  3096. self.wait_for_ready(getattr(self, "monitor", None))
  3097. try:
  3098. load_more_exists = self.d.xpath(load_more_xpath).exists
  3099. except Exception:
  3100. load_more_exists = False
  3101. if load_more_exists:
  3102. load_more_exists_count += 1
  3103. print(f"第{check_round}/{self.load_more_check_rounds}次检测: '加载更多' 存在 "
  3104. f"(累计{load_more_exists_count}次)")
  3105. self.loggerMT.info(
  3106. f"风控卡死检测 第{check_round}/{self.load_more_check_rounds}次: '加载更多' 存在"
  3107. )
  3108. else:
  3109. print(f"第{check_round}/{self.load_more_check_rounds}次检测: '加载更多' 不存在,"
  3110. f"判定为非风控")
  3111. self.loggerMT.info(
  3112. f"风控卡死检测 第{check_round}/{self.load_more_check_rounds}次: '加载更多' 不存在"
  3113. )
  3114. return
  3115. if check_round < self.load_more_check_rounds:
  3116. print(f"等待{self.load_more_check_interval}秒后进行下一次检测...")
  3117. time.sleep(self.load_more_check_interval)
  3118. # 所有轮次都存在"加载更多",判定为风控卡死
  3119. raise WindControlStuckError(
  3120. f"风控卡死确认:'加载更多'按钮{self.load_more_check_rounds}次检测均存在"
  3121. f"(间隔{self.load_more_check_interval}秒),疑似被风控限制无法获取新数据"
  3122. )
  3123. def _record_product_link_result(self, product_link):
  3124. product_link = str(product_link or "").strip()
  3125. if product_link:
  3126. self._reset_product_link_missing_counter()
  3127. return product_link
  3128. self.product_link_missing_count += 1
  3129. print(
  3130. f"当前商品获取不到商品链接,第{self.product_link_missing_count}/"
  3131. f"{self.product_link_missing_threshold}次"
  3132. )
  3133. if self.product_link_missing_count >= self.product_link_missing_threshold:
  3134. raise ProductLinkUnavailableError(
  3135. f"连续{self.product_link_missing_count}次获取不到商品链接,停止采集"
  3136. )
  3137. return ""
  3138. def _check_request_error_after_click(self, timeout=3, interval=0.5):
  3139. deadline = time.time() + timeout
  3140. while time.time() < deadline:
  3141. self.wait_for_ready(getattr(self, "monitor", None))
  3142. try:
  3143. if self.d.xpath('//*[@text="请求错误"]').exists:
  3144. self.request_error_count += 1
  3145. print(
  3146. f"点击商品后检测到请求错误,第{self.request_error_count}/"
  3147. f"{self.request_error_threshold}次"
  3148. )
  3149. if self.request_error_count >= self.request_error_threshold:
  3150. raise AccountBlockedError(
  3151. f"连续点击{self.request_error_count}个商品均出现请求错误,疑似账号被封禁,停止采集"
  3152. )
  3153. return True
  3154. except AccountBlockedError:
  3155. raise
  3156. except Exception:
  3157. pass
  3158. time.sleep(interval)
  3159. return False
  3160. def _enter_list_item_detail(self, item_ctx):
  3161. """步骤3: 点击商品并交给详情页采集"""
  3162. self.safe_exec(item_ctx["drug_one"].click)
  3163. print('点击目标药品完毕')
  3164. if self._check_request_error_after_click():
  3165. self.back_to_list_page()
  3166. return "continue"
  3167. self._reset_request_error_counter()
  3168. integrate_ok = self.safe_exec(lambda: self.integrate_data(prefetched=item_ctx["prefetched_detail"]))
  3169. print('integrate_data结束')
  3170. if not integrate_ok:
  3171. if self._check_request_error_after_click(timeout=1, interval=0.3):
  3172. self.safe_exec(self.back_to_list_page)
  3173. return "continue"
  3174. self.safe_exec(self.back_to_list_page)
  3175. return "collected"
  3176. def _collect_list_item(self, drug_idx, drug_one):
  3177. item_ctx = self._get_list_item_snapshot(drug_idx, drug_one)
  3178. if not item_ctx:
  3179. return "skip"
  3180. collect_state = self._prepare_list_item_collect(item_ctx)
  3181. if collect_state != "ready":
  3182. return collect_state
  3183. return self._enter_list_item_detail(item_ctx)
  3184. def collect_single_product(self):
  3185. if self.monitor and self.monitor.captcha_appearance_limit_reached:
  3186. raise CollectionStopError(
  3187. f"验证码出现过多,1小时内累计出现{len(self.monitor.captcha_appearance_timestamps)}次验证码,终止任务"
  3188. )
  3189. if self.monitor.verification_count >= self.monitor.MAX_VERIFICATION_RETRY:
  3190. raise RuntimeError("验证码触发过多,暂停程序")
  3191. if self.has_reached_target_count():
  3192. print(f"已达到目标采集数量 {self.count} 条,停止采集")
  3193. return False
  3194. if self.page_range:
  3195. self.move_to_page_range_start()
  3196. current_page = self.get_current_page_no()
  3197. if current_page > self.page_range["end"]:
  3198. print(f"当前已在第{current_page}页,超过结束页{self.page_range['end']},停止采集")
  3199. return False
  3200. items = self.get_list_items()
  3201. print(f'当前第{self.get_current_page_no()}页,共有{len(items)}个商品')
  3202. while self.collection_cursor["item_index"] < len(items):
  3203. item_index = self.collection_cursor["item_index"]
  3204. self.collection_cursor["item_index"] += 1
  3205. result = self._collect_list_item(item_index + 1, items[item_index])
  3206. if result == "collected":
  3207. if self.count is not None:
  3208. self._wait_post_process_tasks()
  3209. if self.has_reached_target_count():
  3210. print(f"已达到目标采集数量 {self.count} 条,停止采集")
  3211. return False
  3212. return True
  3213. if result == "continue":
  3214. return True
  3215. if not self.move_to_next_list_page():
  3216. print('已经到达列表页最底部')
  3217. return False
  3218. return True
  3219. def execute_workflow_step(self, step_name):
  3220. if step_name == "start_app":
  3221. self.safe_exec(self.start_collection_app)
  3222. return "open_product_list_page"
  3223. if step_name == "open_product_list_page":
  3224. self.safe_exec(self.open_product_list_page)
  3225. return "collect_single_product"
  3226. if step_name == "collect_single_product":
  3227. has_next = self.safe_exec(self.collect_single_product)
  3228. if not has_next:
  3229. return None
  3230. print('目前连续无关数据量: ', self.unrelated_data)
  3231. if self.unrelated_data > self.max_unrelated_data:
  3232. print(f"连续超过{self.max_unrelated_data}个不达标的数据则停止采集")
  3233. self.finish_task_normally(
  3234. self.get_current_page_no(),
  3235. f"连续超过{self.max_unrelated_data}个不达标的数据则停止采集",
  3236. )
  3237. return None
  3238. return "collect_single_product"
  3239. raise RuntimeError(f"未知流程步骤: {step_name}")
  3240. def main(self, device_id):
  3241. self.device_id = device_id
  3242. self.connect_devices(device_id)
  3243. time.sleep(self.get_sleep_time())
  3244. self.monitor = SpiderMonitor(self)
  3245. self.monitor.start()
  3246. current_step = "start_app"
  3247. step_failures = {step: 0 for step in self.workflow_retry_limit}
  3248. try:
  3249. while current_step:
  3250. try:
  3251. next_step = self.execute_workflow_step(current_step)
  3252. step_failures[current_step] = 0
  3253. current_step = next_step
  3254. except CollectionStopError:
  3255. raise
  3256. except Exception as e:
  3257. # 验证码重试超限,直接终止任务并回告
  3258. if "验证码触发过多" in str(e):
  3259. self.finish_task_abnormally(self.get_current_page_no(), f"验证码处理失败: {e}")
  3260. raise CollectionStopError(str(e))
  3261. print(f'{current_step} 执行异常: {e}')
  3262. time.sleep(3)
  3263. step_failures[current_step] += 1
  3264. retry_limit = self.workflow_retry_limit.get(current_step)
  3265. next_action = self.workflow_error_action.get(current_step)
  3266. record_restart_reason(
  3267. reason="工作流步骤异常,准备按配置重新开始",
  3268. source="workflow",
  3269. device_id=self.device_id,
  3270. task_id=self.task_id,
  3271. step=current_step,
  3272. action=next_action,
  3273. fail_count=step_failures[current_step],
  3274. retry_limit=retry_limit,
  3275. search_key=self.search_key,
  3276. exc=e,
  3277. traceback_text=traceback.format_exc(),
  3278. )
  3279. if step_failures[current_step] > self.workflow_retry_limit[current_step]:
  3280. raise
  3281. current_step = self.handle_workflow_error(current_step)
  3282. return self.finish_task_normally(self.get_current_page_no(), "美团任务执行完成")
  3283. finally:
  3284. self._wait_post_process_tasks()
  3285. self._shutdown_post_process_executor()
  3286. self.monitor.stop()
  3287. self.monitor.join()
  3288. def fetch_task_from_scheduler(scheduler, device_id):
  3289. """从已有的调度器获取一个任务,转换为 device_list 兼容的格式。没有任务返回 None。"""
  3290. task = scheduler.get_task()
  3291. if not task:
  3292. return None
  3293. # start_offset = task.get("current_page", 0) # 移动端起始偏移量,0=从头开始
  3294. start_offset = 0 # 移动端起始偏移量,0=从头开始
  3295. start_page = start_offset if start_offset > 0 else 1
  3296. end_page = task.get("end_page", 0)
  3297. # 转成 page_range 格式,MT.open_product_list_page → move_to_page_range_start 会跳页
  3298. if start_page > 1 or end_page > 0:
  3299. page_range = {"start": start_page, "end": end_page if end_page > 0 else 200}
  3300. else:
  3301. page_range = []
  3302. return {
  3303. "search_key": f"{task.get('product_brand', '')} {task.get('product_name', '')}".strip(),
  3304. "title_key": task.get("product_name", ""),
  3305. "spec_list": parse_spec_list(task.get("product_specs")),
  3306. "brand": task.get("product_brand", ""),
  3307. "sort": "",
  3308. "collect_range": [],
  3309. "page_range": page_range,
  3310. "platform": PLATFORM_MT,
  3311. "task_id": task.get("id"),
  3312. "enterprise_id": task.get("company_id"),
  3313. "equipment_id": task.get("collect_equipment_id", 0),
  3314. "device_name": device_id,
  3315. "collect_equipment_account_id": 0,
  3316. "collect_region_id": 0,
  3317. "collect_round": task.get("collect_round", 1),
  3318. "sampling_cycle": "",
  3319. "sampling_start_time": "",
  3320. "sampling_end_time": "",
  3321. "count": 150,
  3322. "search_task_mode": SEARCH_TASK_MODE,
  3323. "workflow_retry_limit": {
  3324. "start_app": 3,
  3325. "open_product_list_page": 3,
  3326. "collect_single_product": 3,
  3327. },
  3328. "workflow_error_action": {
  3329. "start_app": "start_app",
  3330. "open_product_list_page": "start_app",
  3331. "collect_single_product": "back_to_list_page",
  3332. },
  3333. }
  3334. def run_device(device_id, scheduler=None):
  3335. """单个设备的采集任务。scheduler 由外部传入复用,避免重复启停心跳。"""
  3336. if device_id not in device_list:
  3337. logging.error(f"设备id没有配置: {device_id}")
  3338. return
  3339. own_scheduler = False
  3340. if scheduler is None:
  3341. scheduler = CrawlerScheduler(DEVICE_ID=device_id, platform=str(PLATFORM_MT))
  3342. scheduler.start()
  3343. time.sleep(2)
  3344. own_scheduler = True
  3345. logging.info(f"[设备 {device_id}] 调度器已启动(心跳+回告)")
  3346. tasks = device_list[device_id]
  3347. logging.info(f"[设备 {device_id}] 开始执行,共 {len(tasks)} 个任务")
  3348. for task in tasks:
  3349. mode = task.get("search_task_mode", SEARCH_TASK_MODE)
  3350. variants = build_search_variants(task.get("search_key"), task.get("spec_list"), mode)
  3351. logging.info(
  3352. f"[设备 {device_id}] 任务 {task.get('search_key')} 使用搜索模式 {mode},共 {len(variants)} 组搜索词")
  3353. for variant_idx, variant in enumerate(variants, start=1):
  3354. cycle_no = 0
  3355. while True:
  3356. cycle_no += 1
  3357. mt = None
  3358. variant_search_key = variant["search_key"]
  3359. variant_specs = variant["spec_list"]
  3360. logging.info(
  3361. f'[设备 {device_id}] ========== 搜索组 {variant_idx}/{len(variants)} '
  3362. f'{variant_search_key} 第 {cycle_no} 轮采集开始 =========='
  3363. )
  3364. counter_key = _build_failure_counter_key(
  3365. "local",
  3366. device_id,
  3367. search_key=variant_search_key,
  3368. )
  3369. try:
  3370. mt = MT(
  3371. variant_search_key,
  3372. task["title_key"],
  3373. variant_specs,
  3374. task["brand"],
  3375. task.get("sort"),
  3376. task.get("collect_range"),
  3377. task.get("page_range"),
  3378. task.get("workflow_retry_limit"),
  3379. task.get("workflow_error_action"),
  3380. platform=task.get("platform"),
  3381. task_id=task.get("task_id"),
  3382. enterprise_id=task.get("enterprise_id"),
  3383. sampling_cycle=task.get("sampling_cycle"),
  3384. sampling_start_time=task.get("sampling_start_time"),
  3385. sampling_end_time=task.get("sampling_end_time"),
  3386. count=task.get("count"),
  3387. collect_equipment_id=task.get("collect_equipment_id"),
  3388. device_name=task.get("device_name"),
  3389. collect_equipment_account_id=task.get("collect_equipment_account_id"),
  3390. collect_region_id=task.get("collect_region_id"),
  3391. collect_round=task.get("collect_round"),
  3392. scheduler=scheduler,
  3393. )
  3394. mt.main(device_id)
  3395. logging.info(f'[设备 {device_id}] 搜索组 {variant_idx}/{len(variants)} 本轮采集完成')
  3396. reset_failure_notice_counter(counter_key)
  3397. reset_captcha_restart_count(counter_key)
  3398. break # 成功则跳出当前搜索组重试循环
  3399. except CollectionStopError as e:
  3400. tb_text = traceback.format_exc()
  3401. logging.exception(
  3402. f'[设备 {device_id}] 搜索组 {variant_idx}/{len(variants)} 关键字 {variant_search_key} '
  3403. f'检测到致命停止条件:{e}'
  3404. )
  3405. record_restart_reason(
  3406. reason="run_device 检测到致命停止条件,终止程序",
  3407. source="collection_stop_local",
  3408. device_id=device_id,
  3409. task_id=task.get("task_id"),
  3410. search_key=variant_search_key,
  3411. cycle_no=cycle_no,
  3412. exc=e,
  3413. traceback_text=tb_text,
  3414. )
  3415. if mt is not None:
  3416. end_page = mt.get_current_page_no() if hasattr(mt, "get_current_page_no") else getattr(mt,
  3417. "page",
  3418. 0)
  3419. mt.finish_task_abnormally(end_page, f"任务终止: {e}")
  3420. raise
  3421. except Exception as e:
  3422. tb_text = traceback.format_exc()
  3423. logging.exception(
  3424. f'[设备 {device_id}] 搜索组 {variant_idx}/{len(variants)} 关键字 {variant_search_key} 采集异常:{e}')
  3425. record_restart_reason(
  3426. reason="run_device 捕获异常后进入下一轮重试",
  3427. source="run_device",
  3428. device_id=device_id,
  3429. task_id=task.get("task_id"),
  3430. search_key=variant_search_key,
  3431. cycle_no=cycle_no,
  3432. exc=e,
  3433. traceback_text=tb_text,
  3434. )
  3435. if _is_captcha_related_error(str(e), tb_text):
  3436. captcha_restart_count = increase_captcha_restart_count(counter_key)
  3437. record_restart_reason(
  3438. reason=f"验证码导致重启,1小时窗口内累计第{captcha_restart_count}次",
  3439. source="captcha_restart_alert_local",
  3440. device_id=device_id,
  3441. task_id=task.get("task_id"),
  3442. search_key=variant_search_key,
  3443. fail_count=captcha_restart_count,
  3444. retry_limit=CAPTCHA_STOP_THRESHOLD,
  3445. cycle_no=cycle_no,
  3446. exc=e,
  3447. traceback_text=tb_text,
  3448. )
  3449. # 1小时内验证码导致重启超过阈值 → 停止任务并回告
  3450. if captcha_restart_count >= CAPTCHA_STOP_THRESHOLD:
  3451. logging.error(
  3452. "1小时内验证码导致重启 %s 次,达到阈值 %s,终止当前任务并回告",
  3453. captcha_restart_count, CAPTCHA_STOP_THRESHOLD
  3454. )
  3455. if mt is not None:
  3456. end_page = mt.get_current_page_no() if hasattr(mt, "get_current_page_no") else getattr(mt, "page", 0)
  3457. mt.finish_task_abnormally(
  3458. end_page,
  3459. f"1小时内验证码过多,累计重启{captcha_restart_count}次"
  3460. )
  3461. raise CollectionStopError(
  3462. f"1小时内验证码过多,累计重启{captcha_restart_count}次,终止任务"
  3463. )
  3464. else:
  3465. reset_captcha_restart_count(counter_key)
  3466. _, fail_count, _ = should_send_failure_notice(counter_key, str(e))
  3467. logging.warning(
  3468. "[设备 %s] 搜索组 %s/%s 瞬时入口异常,第%s/%s次",
  3469. device_id, variant_idx, len(variants), fail_count, FAILURE_NOTICE_THRESHOLD
  3470. )
  3471. # 发生异常后继续循环重试,超过上限则回告并停止
  3472. if cycle_no >= MAX_RUN_DEVICE_RETRIES:
  3473. logging.error(
  3474. f'[设备 {device_id}] 搜索组 {variant_idx}/{len(variants)} '
  3475. f'重试{cycle_no}次仍失败,停止: {e}'
  3476. )
  3477. if mt is not None:
  3478. end_page = mt.get_current_page_no() if hasattr(mt, "get_current_page_no") else getattr(mt, "page", 0)
  3479. mt.finish_task_abnormally(end_page, f"重试{cycle_no}次仍失败: {e}")
  3480. break
  3481. finally:
  3482. if mt and hasattr(mt, 'close'):
  3483. mt.close()
  3484. logging.info(f"[设备 {device_id}] 所有任务执行完毕")
  3485. if own_scheduler and scheduler is not None:
  3486. scheduler.stop()
  3487. def main():
  3488. logging.basicConfig(
  3489. level=logging.INFO,
  3490. format='%(asctime)s [%(threadName)s] %(levelname)s: %(message)s'
  3491. )
  3492. # 自动模式:全局只创建一个调度器 + 一个心跳线程,所有任务复用
  3493. scheduler = None
  3494. if not MANUAL_MODE:
  3495. scheduler = CrawlerScheduler(DEVICE_ID=DEVICE_ID, platform=str(PLATFORM_MT))
  3496. scheduler.start()
  3497. time.sleep(2)
  3498. logging.info(f"[{DEVICE_ID}] 调度器已启动(全局心跳线程),开始获取任务...")
  3499. try:
  3500. while True:
  3501. try:
  3502. if MANUAL_MODE:
  3503. if DEVICE_ID not in device_list:
  3504. logging.error(f"设备id没有配置在 device_list 中: {DEVICE_ID}")
  3505. return
  3506. run_device(DEVICE_ID)
  3507. else:
  3508. task = None
  3509. try:
  3510. task = fetch_task_from_scheduler(scheduler, DEVICE_ID)
  3511. except Exception as e:
  3512. logging.exception(f"获取任务失败: {e},{LOOP_INTERVAL_SECONDS}秒后重试...")
  3513. time.sleep(LOOP_INTERVAL_SECONDS)
  3514. continue
  3515. if task is None:
  3516. logging.info(f"当前没有可执行的任务,{LOOP_INTERVAL_SECONDS}秒后重试...")
  3517. time.sleep(LOOP_INTERVAL_SECONDS)
  3518. continue
  3519. logging.info(f"获取到任务: {task.get('search_key')} (task_id={task.get('task_id')})")
  3520. device_list[DEVICE_ID] = [task]
  3521. run_device(DEVICE_ID, scheduler=scheduler)
  3522. logging.info(f"本轮任务完成,等待 {LOOP_INTERVAL_SECONDS} 秒后开始下一轮...")
  3523. except CollectionStopError as e:
  3524. logging.exception(f"检测到致命停止条件,本轮终止: {e}")
  3525. except Exception as e:
  3526. logging.exception(f"本轮任务异常: {e}")
  3527. time.sleep(LOOP_INTERVAL_SECONDS)
  3528. finally:
  3529. if scheduler is not None:
  3530. scheduler.stop()
  3531. logging.info("调度器已停止")
  3532. device_list = {
  3533. "T4VK4LM7AAUOV8AY": [
  3534. {
  3535. "search_key": "金活 依马打正红花油",
  3536. "title_key": "依马打正红花油",
  3537. "spec_list": [''],
  3538. "brand": "金活",
  3539. "sort": "",
  3540. "collect_range": [],
  3541. "page_range": [],
  3542. "platform": 4,
  3543. "task_id": "",
  3544. "enterprise_id": 5,
  3545. "equipment_id": 39,
  3546. "device_name": None,
  3547. "collect_equipment_account_id": 13,
  3548. "collect_region_id": 0,
  3549. "collect_round": 1,
  3550. "sampling_cycle": '1,4',
  3551. "sampling_start_time": 1778774400,
  3552. "sampling_end_time": 1793116799,
  3553. "search_task_mode": "name_with_each_spec",
  3554. "workflow_retry_limit": {
  3555. "start_app": 3,
  3556. "open_product_list_page": 3,
  3557. "collect_single_product": 3,
  3558. },
  3559. "workflow_error_action": {
  3560. "start_app": "start_app",
  3561. "open_product_list_page": "start_app",
  3562. "collect_single_product": "back_to_list_page",
  3563. }
  3564. },
  3565. ],
  3566. }
  3567. if __name__ == '__main__':
  3568. main()