start_run_pdd.py 162 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559
  1. # coding=utf-8
  2. from glob import glob1
  3. from area_info.city_name_to_id import get_city
  4. import requests
  5. import base64
  6. import uiautomator2 as u2
  7. import time
  8. import sys
  9. import builtins
  10. import subprocess
  11. import re
  12. import random
  13. import json
  14. from aip import AipOcr
  15. import numpy as np
  16. import cv2
  17. import os
  18. from spiders.pdd.pdd_config import Config
  19. import logging
  20. import pymysql
  21. import datetime
  22. import threading
  23. import oss2
  24. import uuid
  25. from PIL import Image
  26. from pathlib import Path
  27. from spiders.pdd.test_captcha import solve as captcha_solve
  28. from commons.Logger import get_spider_logger
  29. from commons.feishu_webhook import send_text, send_error_card
  30. from commons.scheduler import CrawlerScheduler
  31. _DEFAULT_PATH = Path(__file__).parent / "spiders" / "pdd" / "city.json"
  32. GLOBAL_DEVICE_SAVED_COUNTS = {}
  33. # 初始化统一日志(控制台 + logs/pdd_new2.log)
  34. _pdd_logger = get_spider_logger("pdd")
  35. logging.root.handlers = _pdd_logger.handlers
  36. logging.root.setLevel(_pdd_logger.level)
  37. # 保留原 print 的调用方式,但统一写入日志,避免到处翻控制台输出。
  38. # 说明:
  39. # 1. 现有代码里的 print(...) 不需要逐个改;
  40. # 2. 默认输出会走 logging,自动带时间、级别并写文件;
  41. # 3. 只有当 print(file=某文件对象) 时,才回退到原生 print,避免改变文件写入语义。
  42. _ORIGINAL_PRINT = builtins.print
  43. def print(*args, sep=" ", end="\n", file=None, flush=False):
  44. message = sep.join(str(arg) for arg in args)
  45. if end and end != "\n":
  46. message = f"{message}{end}"
  47. logged = False
  48. try:
  49. spider_logger = logging.getLogger("spider.pdd")
  50. if spider_logger.handlers:
  51. spider_logger.info(message.rstrip("\n"))
  52. logged = True
  53. except Exception:
  54. logged = False
  55. # 日志链路异常或未初始化时,至少保证能在控制台看到输出
  56. if not logged:
  57. _ORIGINAL_PRINT(*args, sep=sep, end=end, file=file if file else sys.stdout, flush=flush)
  58. if file not in (None, sys.stdout, sys.stderr):
  59. _ORIGINAL_PRINT(*args, sep=sep, end=end, file=file, flush=flush)
  60. # 功能:这个模块负责从数据库拉取拼多多待执行任务,把任务分发到空闲设备,
  61. # 然后在单设备线程中驱动 App 完成搜索、采集、校验、去重和落库。
  62. # 边界:这里主要做调度和采集流程编排,不负责数据库表结构定义,也不负责
  63. # OCR 服务、滑块识别服务或盒数提取逻辑本身的实现,它们都通过外部依赖完成。
  64. # 数据库连接池(与 YBM 同库:120.24.26.108:3307/drug_retrieve,但不用 DictCursor)
  65. from dbutils.pooled_db import PooledDB
  66. _db_pool = PooledDB(
  67. creator=pymysql,
  68. maxconnections=10,
  69. mincached=2,
  70. maxcached=5,
  71. blocking=True,
  72. charset="utf8mb4",
  73. host="120.24.26.108",
  74. port=3307,
  75. user="root",
  76. password="zhijiayun123456",
  77. database="drug_retrieve",
  78. )
  79. def get_mysql():
  80. """从连接池获取数据库连接(兼容旧接口)。调用方负责 conn.close() 归还连接。"""
  81. return _db_pool.connection()
  82. def get_access_token():
  83. AppKey = "1gAzACJOAr7BeILKqkqPOETh"
  84. AppSrcret = "ZNArANb9GwJYgLKg4EfYhukKBfPdl1n3"
  85. token_url = 'https://aip.baidubce.com/oauth/2.0/token'
  86. url = f"{token_url}?grant_type=client_credentials&client_id={AppKey}&client_secret={AppSrcret}"
  87. payload = ""
  88. headers = {
  89. 'Content-Type': 'application/json',
  90. 'Accept': 'application/json'
  91. }
  92. response = requests.request("POST", url, headers=headers, data=payload)
  93. try:
  94. return response.json()['access_token']
  95. except:
  96. return None
  97. # 心跳上报配置
  98. HEARTBEAT_INTERVAL_SECONDS = 60 # 心跳上报间隔(秒)
  99. HEARTBEAT_API_URL = "http://pricesys.kailin.com.cn:8083/api/collect_task/heartbeat" # 与 YBM 共用
  100. SCHEDULER_INTERVAL_SECONDS = 600
  101. MANUAL_SCHEDULER_INTERVAL_SECONDS = 300
  102. PLATFORM_PDD = 3
  103. TASK_STATUS_PENDING = 1
  104. DEVICE_STATUS_IDLE = 0
  105. DEFAULT_MAX_COUNTS_LIMIT = 300
  106. PLATFORM_NAME_MAP = {
  107. PLATFORM_PDD: "拼多多",
  108. str(PLATFORM_PDD): "拼多多",
  109. }
  110. BRAND_ALIAS_MAP = {
  111. "小葵花": ["葵花"],
  112. "999": ['三九'],
  113. }
  114. # 手动任务模式:
  115. # False -> 保持原有数据库取任务逻辑
  116. # True -> 不从数据库取任务,直接使用 MANUAL_TASKS
  117. USE_MANUAL_TASKS = False
  118. # 手动任务示例(按需修改,可配置多条)
  119. # 必填:search_key;device_id 不需要填写,手动模式会自动选择空闲 PDD 设备。
  120. MANUAL_TASKS = []
  121. task_list = [
  122. ("金活依马打正红花油", "依马打正红花油", [], "金活"), ("金活依马打正红花油", "依马打正红花油", [], "金活"),
  123. ("金活依马打正红花油", "依马打正红花油", [], "金活"), ("金活依马打正红花油", "依马打正红花油", [], "金活"),
  124. ("金活依马打正红花油", "依马打正红花油", [], "金活"), ("金活依马打正红花油", "依马打正红花油", [], "金活"),
  125. ("金活依马打正红花油", "依马打正红花油", [], "金活"), ("金活依马打正红花油", "依马打正红花油", [], "金活"),
  126. ]
  127. for search_key, title_key, spec_list, brand in task_list:
  128. MANUAL_TASKS.append({
  129. "search_key": search_key,
  130. "title_key": title_key,
  131. "spec_list": spec_list,
  132. "brand": brand,
  133. "save_search_key": search_key,
  134. "enterprise_id": 8,
  135. "start_page": 0,
  136. "end_page": 500,
  137. "max_counts_limit": 500,
  138. "sort": "升序",
  139. "collect_round": 2,
  140. "collect_equipment_account_id": 1,
  141. "collect_region_id": 1,
  142. })
  143. # 这些集合只表示"当前进程里的占用状态"。
  144. # 数据库里设备仍可能显示空闲,因此调度前后都要结合这几份内存状态做去重。
  145. dispatch_lock = threading.Lock()
  146. running_task_ids = set()
  147. running_device_ids = set()
  148. worker_threads = {}
  149. scheduler_stop_event = threading.Event()
  150. scheduler_timer = None
  151. manual_dispatched_task_ids = set()
  152. def get_platform_name(platform):
  153. if platform in (None, ""):
  154. return "拼多多"
  155. return PLATFORM_NAME_MAP.get(platform, PLATFORM_NAME_MAP.get(str(platform), str(platform)))
  156. # 功能:把外部输入安全转成整数;当值为空或格式不合法时回退到默认值。
  157. # 输入约束:允许传入 None、空字符串、数字字符串或整数。
  158. # 返回:成功时返回 int,失败时返回 default。
  159. def parse_optional_int(value, default=None):
  160. if value in (None, ""):
  161. return default
  162. try:
  163. return int(value)
  164. except (TypeError, ValueError):
  165. return default
  166. def get_adb_device_status_map():
  167. # 功能:读取 ADB 真实设备状态;只有状态为 device 才认为可执行自动化。
  168. try:
  169. result = subprocess.run(
  170. ["adb", "devices"],
  171. capture_output=True,
  172. text=True,
  173. timeout=10,
  174. )
  175. except Exception as e:
  176. logging.exception(f"读取 ADB 设备状态失败: {e}")
  177. return {}
  178. status_map = {}
  179. for line in result.stdout.splitlines():
  180. line = line.strip()
  181. if not line or line.startswith("List of devices"):
  182. continue
  183. parts = line.split()
  184. if len(parts) >= 2:
  185. status_map[parts[0]] = parts[1]
  186. return status_map
  187. def is_adb_device_online(device_id, adb_status_map=None):
  188. device_id = str(device_id or "").strip()
  189. if not device_id:
  190. return False
  191. if adb_status_map is None:
  192. adb_status_map = get_adb_device_status_map()
  193. return adb_status_map.get(device_id) == "device"
  194. def fetch_idle_pdd_devices():
  195. # 功能:读取数据库里所有空闲的拼多多设备;真实在线状态由 ADB 再二次判断。
  196. conn = None
  197. try:
  198. conn = get_mysql()
  199. with conn.cursor() as cursor:
  200. sql = """
  201. SELECT *
  202. FROM retrieve_collect_equipment_account
  203. WHERE platform = %s AND status = %s
  204. ORDER BY id ASC
  205. """
  206. cursor.execute(sql, (PLATFORM_PDD, DEVICE_STATUS_IDLE))
  207. return cursor.fetchall()
  208. except Exception as e:
  209. logging.exception(f"读取空闲 PDD 设备失败: {e}")
  210. return []
  211. finally:
  212. if conn:
  213. conn.close()
  214. def fetch_manual_task_payloads():
  215. payloads = []
  216. idle_devices = list(fetch_idle_pdd_devices())
  217. adb_status_map = get_adb_device_status_map()
  218. idle_device_idx = 0
  219. for idx, task in enumerate(MANUAL_TASKS):
  220. task_id = parse_optional_int(task.get("task_id"), 900000 + idx + 1)
  221. if task_id in manual_dispatched_task_ids:
  222. continue
  223. search_key = str(task.get("search_key", "")).strip()
  224. if not search_key:
  225. logging.warning(f"跳过手动任务,缺少 search_key: {task}")
  226. continue
  227. device_row = None
  228. while idle_device_idx < len(idle_devices):
  229. candidate_device = idle_devices[idle_device_idx]
  230. idle_device_idx += 1
  231. candidate_device_id = str(candidate_device[11]).strip()
  232. if not is_adb_device_online(candidate_device_id, adb_status_map):
  233. logging.info(
  234. f"手动任务 {task_id} 跳过 ADB 非在线设备 {candidate_device_id}, "
  235. f"status={adb_status_map.get(candidate_device_id, 'missing')}"
  236. )
  237. continue
  238. with dispatch_lock:
  239. if candidate_device_id in running_device_ids:
  240. continue
  241. running_task_ids.add(task_id)
  242. running_device_ids.add(candidate_device_id)
  243. device_row = candidate_device
  244. break
  245. if not device_row:
  246. logging.info(f"手动任务 {task_id} 没有可用空闲设备,本轮跳过")
  247. continue
  248. device_id = str(device_row[11]).strip()
  249. title_key = task.get("title_key")
  250. save_search_key = task.get("save_search_key")
  251. payloads.append({
  252. "task_id": task_id,
  253. "equipment_id": device_row[1],
  254. "enterprise_id": parse_optional_int(task.get("enterprise_id"), 0),
  255. "platform": parse_optional_int(task.get("platform"), PLATFORM_PDD),
  256. "title_key": title_key if title_key not in (None, "") else search_key,
  257. "spec_list": task.get("spec_list", ""),
  258. "brand": task.get("brand", ""),
  259. "search_key": search_key,
  260. "save_search_key": save_search_key if save_search_key not in (None, "") else search_key,
  261. "start_page": parse_optional_int(task.get("start_page"), 0),
  262. "end_page": parse_optional_int(task.get("end_page"), None),
  263. "max_counts_limit": parse_optional_int(task.get("max_counts_limit"), DEFAULT_MAX_COUNTS_LIMIT),
  264. "collect_config_info": task.get("collect_config_info", ""),
  265. 'collect_equipment_account_id': task.get("collect_equipment_account_id", ""),
  266. 'collect_region_id': task.get("collect_region_id", ""),
  267. 'collect_round': task.get("collect_round", ""),
  268. "sort": task.get("sort", "默认"),
  269. "device_id": device_id,
  270. "task_row": None,
  271. "direct_shop_lookup": bool(task.get("direct_shop_lookup", False)),
  272. })
  273. manual_dispatched_task_ids.add(task_id)
  274. if payloads:
  275. logging.info(f"手动任务模式启用,本轮分发 {len(payloads)} 条任务")
  276. else:
  277. logging.info("手动任务模式启用,但没有可分发的新任务")
  278. return payloads
  279. def fetch_pending_tasks():
  280. """通过调度 API 为每个空闲 PDD 设备拉取任务(替代原 DB 查询 retrieve_collect_task_allocate)。"""
  281. tasks = []
  282. idle_devices = fetch_idle_pdd_devices()
  283. if not idle_devices:
  284. logging.info("当前没有空闲 PDD 设备")
  285. return tasks
  286. for device_row in idle_devices:
  287. username = device_row[4] # 调度系统注册的账号名(如 pdd_1)
  288. device_id = device_row[11] or username # ADB 序列号,空时用 username 兜底
  289. if not username:
  290. continue
  291. with dispatch_lock:
  292. if device_id in running_device_ids:
  293. continue
  294. # 用 username 向调度 API 拉任务(先发心跳注册账号,跟 YBM 一样)
  295. scheduler = CrawlerScheduler(username, str(PLATFORM_PDD))
  296. requests.post(
  297. scheduler.heartbeat_url,
  298. json={"platform": scheduler.platform, "username": scheduler.username},
  299. headers={'X-Crawler-Token': 'zhijiayun_crawler_2026'},
  300. timeout=5,
  301. )
  302. task = scheduler.get_task()
  303. if task:
  304. task['_device_row'] = device_row
  305. task['_device_id'] = device_id
  306. tasks.append(task)
  307. return tasks
  308. def fetch_idle_device_by_equipment_id(equipment_id):
  309. # 功能:按设备 id 查询指定终端是否空闲,避免任务和设备错配。
  310. # 调用 get_mysql() 的目的是查询指定设备是否空闲,避免把任务错误派发到其他终端。
  311. conn = None
  312. try:
  313. conn = get_mysql()
  314. with conn.cursor() as cursor:
  315. sql = """
  316. SELECT *
  317. FROM retrieve_collect_equipment_account
  318. WHERE collect_equipment_id = %s AND status = %s
  319. LIMIT 1
  320. """
  321. cursor.execute(sql, (equipment_id, DEVICE_STATUS_IDLE))
  322. return cursor.fetchone()
  323. except Exception as e:
  324. logging.exception(f"读取空闲设备失败 equipment_id={equipment_id}: {e}")
  325. return None
  326. finally:
  327. if conn:
  328. conn.close()
  329. def build_task_payload(task_row, device_row):
  330. # 功能:把数据库原始任务行和设备行整理成线程入口可直接消费的任务上下文。
  331. # 返回:统一字段名的字典,避免后续线程逻辑继续依赖固定列下标。
  332. sampling_cycle = str(task_row[12]).strip() if len(task_row) > 12 else ''
  333. sampling_start_time = parse_optional_int(task_row[13] if len(task_row) > 13 else None, 0)
  334. sampling_end_time = parse_optional_int(task_row[14] if len(task_row) > 14 else None, 0)
  335. start_page = parse_optional_int(task_row[15] if len(task_row) > 15 else None, 0)
  336. end_page = parse_optional_int(task_row[16] if len(task_row) > 16 else None, 300)
  337. max_counts_limit = parse_optional_int(
  338. task_row[17] if len(task_row) > 17 else None,
  339. DEFAULT_MAX_COUNTS_LIMIT
  340. )
  341. collect_config_info = json.dumps({
  342. "sampling_cycle": sampling_cycle,
  343. "sampling_start_time": sampling_start_time,
  344. "sampling_end_time": sampling_end_time,
  345. }, ensure_ascii=False)
  346. return {
  347. "task_id": task_row[0],
  348. "task_name": task_row[1],
  349. "collect_equipment_account_id": task_row[3],
  350. "collect_region_id": task_row[4],
  351. "equipment_id": task_row[2],
  352. "enterprise_id": task_row[5],
  353. "platform": task_row[6],
  354. "title_key": task_row[7],
  355. "spec_list": task_row[8],
  356. "brand": task_row[9],
  357. "search_key": f"{task_row[9]}{task_row[7]} {task_row[8] or ''}".strip(),
  358. "save_search_key": f"{task_row[9]}{task_row[7]} {task_row[8] or ''}".strip(),
  359. "start_page": start_page,
  360. "end_page": end_page,
  361. "max_counts_limit": max_counts_limit,
  362. "collect_config_info": collect_config_info,
  363. "collect_round": task_row[24],
  364. "sort": "升序",
  365. "device_id": device_row[11],
  366. "task_row": task_row,
  367. }
  368. def build_task_payload_from_api(task_dict, device_row):
  369. """从调度 API 返回的 task dict + DB 设备行构建 worker 线程可消费的 payload。"""
  370. username = device_row # 调度系统账号名(如 pdd_1)
  371. device_id = task_dict['_device_id'] or username # ADB 序列号,空时用 username 兜底
  372. search_key = (
  373. f"{task_dict.get('product_brand', '')}"
  374. f"{task_dict.get('product_name', '')} "
  375. f"{task_dict.get('product_specs', '') or ''}"
  376. ).strip()
  377. spec_raw = task_dict.get('product_specs', '') or ''
  378. spec_list = [s.strip() for s in re.split(r'[|、,,\n\r]+', spec_raw) if s.strip()]
  379. collect_config_info = json.dumps({
  380. "sampling_cycle": task_dict.get('sampling_cycle', ''),
  381. "sampling_start_time": task_dict.get('sampling_start_time', 0),
  382. "sampling_end_time": task_dict.get('sampling_end_time', 0),
  383. }, ensure_ascii=False)
  384. return {
  385. "task_id": task_dict.get('id'),
  386. "search_key": search_key,
  387. "title_key": task_dict.get('product_name'),
  388. "spec_list": spec_list,
  389. "brand": task_dict.get('product_brand', ''),
  390. "save_search_key": search_key,
  391. "start_page": 0, # 取消恢复历史进度,取到任务就从第一个商品开始
  392. "end_page": None,
  393. "max_counts_limit": DEFAULT_MAX_COUNTS_LIMIT,
  394. "collect_config_info": collect_config_info,
  395. "platform": PLATFORM_PDD,
  396. "enterprise_id": task_dict.get('company_id'),
  397. "collect_round": task_dict.get('collect_round'),
  398. "collect_equipment_account_id": task_dict.get('collect_equipment_account_id', 1),
  399. "collect_region_id": task_dict.get('collect_region_id', 1),
  400. "sort": "升序",
  401. "username": username, # 调度 API 账号名
  402. "device_id": device_id, # ADB 序列号
  403. "device_row": device_row,
  404. }
  405. def fetch_runnable_task_payloads():
  406. """通过调度 API 拉取任务,结合 DB 设备空闲状态,生成可执行的 payload 列表。"""
  407. if USE_MANUAL_TASKS:
  408. logging.info("手动任务模式启用,跳过 API 任务分配")
  409. return fetch_manual_task_payloads()
  410. api_tasks = fetch_pending_tasks()
  411. if not api_tasks:
  412. logging.info("当前没有待执行任务")
  413. return []
  414. payloads = []
  415. for task_dict in api_tasks:
  416. device_row = task_dict.get('_device_row')
  417. device_id = task_dict.get('_device_id')
  418. task_id = task_dict.get('id')
  419. if not device_row or not device_id:
  420. continue
  421. with dispatch_lock:
  422. if device_id in running_device_ids:
  423. logging.info(f"设备 {device_id} 已在本进程执行任务,跳过任务 {task_id}")
  424. continue
  425. running_device_ids.add(device_id)
  426. payloads.append(build_task_payload_from_api(task_dict, device_row))
  427. return payloads
  428. def cleanup_finished_workers():
  429. # 功能:同步 worker_threads / running_device_ids / running_task_ids,
  430. # 只保留仍然存活的线程引用,避免失效线程长期占位导致设备和任务被永久跳过。
  431. with dispatch_lock:
  432. dead_device_ids = [
  433. device_id
  434. for device_id, thread in worker_threads.items()
  435. if not thread.is_alive()
  436. ]
  437. for device_id in dead_device_ids:
  438. worker_threads.pop(device_id, None)
  439. running_device_ids.discard(device_id)
  440. orphan_device_ids = [
  441. device_id
  442. for device_id in running_device_ids
  443. if device_id not in worker_threads
  444. ]
  445. for device_id in orphan_device_ids:
  446. running_device_ids.discard(device_id)
  447. if not worker_threads and running_task_ids:
  448. logging.info(
  449. f"cleanup: 无存活 worker,清空残留 running_task_ids: {running_task_ids}"
  450. )
  451. running_task_ids.clear()
  452. def wait_for_active_workers(check_interval=1):
  453. while True:
  454. cleanup_finished_workers()
  455. with dispatch_lock:
  456. alive_threads = [thread for thread in worker_threads.values() if thread.is_alive()]
  457. if not alive_threads:
  458. return
  459. time.sleep(check_interval)
  460. def has_active_workers():
  461. cleanup_finished_workers()
  462. with dispatch_lock:
  463. return any(thread.is_alive() for thread in worker_threads.values())
  464. def all_manual_tasks_dispatched():
  465. expected_task_ids = {
  466. parse_optional_int(task.get("task_id"), 900000 + idx + 1)
  467. for idx, task in enumerate(MANUAL_TASKS)
  468. }
  469. return expected_task_ids.issubset(manual_dispatched_task_ids)
  470. def run_sechduler_work(device):
  471. spider_schedule = CrawlerScheduler(device.get('_device_id'), str(PLATFORM_PDD))
  472. spider_schedule.start() # 启动一次后台心跳线程(让它一直保持存活即可)
  473. time.sleep(3)
  474. device_id = device.get('_device_id') or device.get('_device_row')
  475. while True:
  476. try:
  477. # 设备掉线时不领任务:离线领任务只会立刻失败并被服务器重派,
  478. # 形成"领取-失败-重派"空转循环(日志里曾单设备刷出 500+ 次)。
  479. if not is_adb_device_online(device_id):
  480. logging.info(f"设备 {device_id} 不在线,跳过本轮领取,120秒后重试")
  481. time.sleep(120)
  482. continue
  483. print('尝试获取任务...')
  484. task_dict = spider_schedule.get_task()
  485. # 1. 没有任务就休息 35 秒
  486. if not task_dict:
  487. time.sleep(35)
  488. continue
  489. print('进入工作')
  490. task_dict['_device_row'] = device['_device_row']
  491. task_dict['_device_id'] = device['_device_id']
  492. # 2. 执行核心任务
  493. crawl_count, is_success = run_task_worker(
  494. build_task_payload_from_api(task_dict, task_dict['_device_row']),
  495. spider_schedule
  496. )
  497. # 3. 做完任务统一休息 60 秒(不再去来回启停心跳了)
  498. print('当前任务完成,休息中...')
  499. time.sleep(60)
  500. except Exception as e:
  501. _pdd_logger.error(f"调度循环发生异常: {e}")
  502. time.sleep(30)
  503. def run_task_worker(task_payload, spider_schedule):
  504. """单个任务线程的主入口:通过 CrawlerScheduler 维持心跳,驱动 PDD 采集并上报。"""
  505. task_id = task_payload["task_id"]
  506. device_id = task_payload["device_id"] # ADB 序列号(u2.connect_usb 用)
  507. username = task_payload.get("username", device_id) # 调度系统账号名
  508. search_key = str(task_payload.get("search_key", "")).strip()
  509. pdd = None
  510. should_send_finish_notice = False
  511. try:
  512. logging.info(f"[任务 {task_id}] 开始执行,设备: {device_id}")
  513. print(task_payload)
  514. pdd = PDD(
  515. task_payload["search_key"],
  516. device_id,
  517. title_key=task_payload.get("title_key"),
  518. spec_list=task_payload.get("spec_list"),
  519. brand=task_payload.get("brand", ""),
  520. save_search_key=task_payload.get("save_search_key"),
  521. start_page=task_payload.get("start_page"),
  522. end_page=task_payload.get("end_page"),
  523. max_counts_limit=task_payload.get("max_counts_limit"),
  524. collect_config_info=task_payload.get("collect_config_info", ""),
  525. direct_shop_lookup=task_payload.get("direct_shop_lookup", False),
  526. sort=task_payload.get("sort"),
  527. platform=task_payload.get("platform"),
  528. task_id=task_payload.get("task_id"),
  529. enterprise_id=task_payload.get("enterprise_id"),
  530. collect_round=task_payload.get("collect_round"),
  531. collect_equipment_account_id=task_payload.get("collect_equipment_account_id", 1),
  532. collect_region_id=task_payload.get("collect_region_id", 1),
  533. username=task_payload.get("username"),
  534. scheduler=spider_schedule
  535. )
  536. completed_normally = pdd.main(device_id, 1, 0)
  537. if completed_normally:
  538. logging.info(f"[任务 {task_id}] 执行完成,设备: {device_id}")
  539. else:
  540. logging.info(f"[任务 {task_id}] 已结束,设备: {device_id}")
  541. should_send_finish_notice = True
  542. except Exception as e:
  543. end_page = task_payload.get("start_page")
  544. err_msg = str(e)
  545. if pdd is not None:
  546. end_page = getattr(pdd, "page", end_page)
  547. logging.exception(f"[任务 {task_id}] 执行异常,设备: {device_id},错误: {err_msg}")
  548. send_error_card(
  549. task_name=f"PDD任务(task_id={task_id}, device={device_id}, key={search_key})",
  550. err_msg=err_msg,
  551. mention_all=False,
  552. )
  553. should_send_finish_notice = True
  554. finally:
  555. # 注意:这里不能释放 running_device_ids / worker_threads。
  556. # 设备占用标记属于 run_sechduler_work 常驻线程的生命周期(线程死亡时由
  557. # cleanup_finished_workers 统一回收);任务结束就释放会让定时派单误以为
  558. # 设备空闲,为同一台手机再起一个领任务线程,导致两个采集会话并发抢手机。
  559. if pdd is not None:
  560. try:
  561. pdd.ensure_home_before_task_end(max_rounds=6)
  562. except Exception as cleanup_error:
  563. logging.info(f"[任务 {task_id}] 收尾回主页面失败: {cleanup_error}")
  564. if should_send_finish_notice:
  565. scraped_count = getattr(pdd, "max_counts", 0) if pdd is not None else 0
  566. notify_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  567. platform_name = get_platform_name(task_payload.get("platform"))
  568. drug_name = str(task_payload.get("search_key", "")).strip()
  569. send_text(
  570. f"{notify_time} 通知:\n"
  571. f"平台: {platform_name}, 药品: {drug_name}, 爬取数据: {scraped_count}条"
  572. )
  573. return scraped_count, should_send_finish_notice
  574. def dispatch_pending_tasks():
  575. # 功能:执行一轮派单,把每个可运行任务绑定到对应设备线程。
  576. # 阶段 1:先清理已经结束的线程引用,避免占用状态过期。
  577. cleanup_finished_workers()
  578. # 阶段 2:重新计算本轮真正可运行的任务集合。
  579. # 手动改任务处
  580. # task_payloads = fetch_runnable_task_payloads()
  581. task_payloads = fetch_idle_pdd_devices()
  582. _pdd_logger.info(f":::{task_payloads}")
  583. for task_payload in task_payloads:
  584. task = {}
  585. task['_device_row'] = task_payload[4] # username
  586. task['_device_id'] = task_payload[11]
  587. device_id = task["_device_id"]
  588. print(device_id)
  589. with dispatch_lock:
  590. if device_id in running_device_ids:
  591. logging.info(f"设备 {device_id} 已在本进程执行任务")
  592. continue
  593. running_device_ids.add(device_id)
  594. try:
  595. # 阶段 3:为每个任务创建独立线程,让不同设备可以并发执行采集。
  596. thread = threading.Thread(
  597. target=run_sechduler_work,
  598. args=(task,),
  599. daemon=True,
  600. name=f"pdd-{device_id}",
  601. )
  602. with dispatch_lock:
  603. worker_threads[device_id] = thread
  604. # worker_threads.update({device_id:thread})
  605. thread.start()
  606. except Exception as e:
  607. print('错误原因:', e)
  608. with dispatch_lock:
  609. # 线程创建失败时同步回滚占用状态,避免任务被"卡住"。
  610. running_task_ids.discard(task_payload["task_id"])
  611. running_device_ids.discard(device_id)
  612. worker_threads.pop(device_id, None)
  613. raise
  614. def schedule_dispatch(delay_seconds=SCHEDULER_INTERVAL_SECONDS, job_func=None):
  615. # 功能:安排下一次轮询触发时间。
  616. # 功能:注册下一轮调度定时器,让调度器按固定间隔持续轮询。
  617. global scheduler_timer
  618. if scheduler_stop_event.is_set():
  619. return
  620. if job_func is None:
  621. job_func = scheduled_dispatch_job
  622. # 采用递归定时器而不是死循环,便于后续统一停机。
  623. scheduler_timer = threading.Timer(delay_seconds, job_func)
  624. scheduler_timer.daemon = False
  625. scheduler_timer.name = "pdd-scheduler"
  626. scheduler_timer.start()
  627. def scheduled_dispatch_job():
  628. # 功能:执行一次定时派单,并保证下一轮轮询仍会继续注册。
  629. # 功能:包装一次定时调度执行,确保本轮出错也不会中断后续轮询。
  630. try:
  631. dispatch_pending_tasks()
  632. except Exception as e:
  633. logging.exception(f"PDD 定时调度异常: {e}")
  634. finally:
  635. schedule_dispatch(SCHEDULER_INTERVAL_SECONDS)
  636. def manual_scheduled_dispatch_job():
  637. try:
  638. dispatch_pending_tasks()
  639. except Exception as e:
  640. logging.exception(f"PDD 手动任务定时调度异常: {e}")
  641. finally:
  642. if all_manual_tasks_dispatched() and not has_active_workers():
  643. logging.info("手动任务模式:全部任务已完成,停止轮询并退出")
  644. scheduler_stop_event.set()
  645. return
  646. schedule_dispatch(MANUAL_SCHEDULER_INTERVAL_SECONDS, manual_scheduled_dispatch_job)
  647. # 调度上报配置(与 YBM 共用同一套 API)
  648. TASK_REPORT_URL = "http://pricesys.kailin.com.cn:8083/api/collect_task/report"
  649. TASK_HEARTBEAT_URL = "http://pricesys.kailin.com.cn:8083/api/collect_task/heartbeat"
  650. TASK_API_HEADERS = {'X-Crawler-Token': 'zhijiayun_crawler_2026'}
  651. # 获取滑块验证中滑块需要移动的距离
  652. def slide_verify(img_path):
  653. # 功能:把本地截图交给第三方打码服务,换回滑块缺口位移。
  654. # 返回:识别成功时返回滑块位移结果,失败时返回服务端给出的空结果。
  655. with open(img_path, 'rb') as f:
  656. b = base64.b64encode(f.read()).decode() ## 图片二进制流base64字符串
  657. url = "http://api.jfbym.com/api/YmServer/customApi"
  658. data = {
  659. ## 关于参数,一般来说有3个;不同类型id可能有不同的参数个数和参数名,找客服获取
  660. "token": "1nDVocTE2mJ0yLEYb2sZJ5uUY2VIEoGTkIpW44X7Kgk",
  661. "type": "22222",
  662. "image": b,
  663. }
  664. _headers = {
  665. "Content-Type": "application/json"
  666. }
  667. # 识别结果来自第三方服务,当前逻辑只读取它约定的 msg/data 字段。
  668. response = requests.request("POST", url, headers=_headers, json=data).json()
  669. print(response)
  670. if response.get("msg") == "识别成功":
  671. # 获取 data 中的 data 字段
  672. result = response.get("data", {}).get("data")
  673. if result:
  674. print(result) # 输出结果
  675. else:
  676. print("无法获取数据")
  677. else:
  678. print("识别未成功")
  679. return result
  680. class PDD:
  681. # 功能:这个类负责维护单次拼多多采集任务的运行状态,并封装设备连接、
  682. # 页面导航、数据提取、说明书解析、去重校验和写库等操作。
  683. # 边界:这个类负责"如何跑完整个采集流程",但不负责外部调度器的轮询策略,
  684. # 也不负责 OCR/盒数提取等外部依赖的底层实现。
  685. def __init__(
  686. self,
  687. search_key,
  688. device_id,
  689. title_key=None,
  690. spec_list=None,
  691. brand="",
  692. save_search_key=None,
  693. start_page=0,
  694. end_page=None,
  695. max_counts_limit=None,
  696. collect_config_info="",
  697. direct_shop_lookup=False,
  698. sort=None,
  699. platform=None,
  700. task_id=None,
  701. enterprise_id=None,
  702. collect_round=None,
  703. collect_equipment_account_id=None,
  704. collect_region_id=None,
  705. username=None,
  706. scheduler=None,
  707. ):
  708. # 阶段 1:初始化与 App、OCR、日志、OSS 相关的基础依赖。
  709. self.username = username # 调度系统账号名
  710. self.package_name = 'com.xunmeng.pinduoduo'
  711. self.APP_ID = '116857964'
  712. self.API_KEY = '1gAzACJOAr7BeILKqkqPOETh'
  713. self.SECRET_KEY = 'ZNArANb9GwJYgLKg4EfYhukKBfPdl1n3'
  714. self.client = AipOcr(self.APP_ID, self.API_KEY, self.SECRET_KEY)
  715. self.table_name = "retrieve_scrape_data" # "pdd_drug"
  716. self.shop_table_name = "retrieve_scrape_shop_info" # "pdd_shop_info"
  717. self.loggerPdd = logging.getLogger()
  718. self.clipboard = "" # 初始化剪切板的内容为空
  719. # 阶段 2:固化本次任务的筛选条件、页码边界和搜索参数。
  720. self.enterprise_id = enterprise_id
  721. self.task_id = task_id
  722. self.platform = platform
  723. self.sort = sort
  724. self.sort_key = 0
  725. self.search_key = search_key # 参苓健脾胃颗粒 香砂平胃颗粒 舒肝颗粒 清肺化痰丸
  726. # title_key 支持把"搜索词"和"标题过滤词"拆开;未单独传入时回退到搜索词。
  727. self.title_key = title_key if title_key is not None else search_key
  728. # 规格统一整理成列表,后续匹配逻辑就不用再分辨单值、列表和空值三种输入。
  729. self.spec_list = self._normalize_rule_list(spec_list)
  730. # 统一做一次清洗,避免 None/空白写入数据库。
  731. self.brand = str(brand or "").strip()
  732. self.save_search_key = save_search_key or search_key
  733. # 起止页在入口阶段先做边界修正,主循环只消费规范化后的值。
  734. self.start_page = max(parse_optional_int(start_page, 0), 0)
  735. self.end_page = parse_optional_int(end_page, None) # None = 不限页数
  736. self.max_counts_limit = max_counts_limit
  737. self.collect_config_info = collect_config_info or ""
  738. self.direct_shop_lookup = direct_shop_lookup
  739. # 任务轮次(数据库字段 collect_round)
  740. self.collect_round = collect_round
  741. self.collect_equipment_account_id = collect_equipment_account_id
  742. self.collect_region_id = collect_region_id
  743. self.unrelated_data = 0 # 无关数据数量
  744. self.device_id = device_id
  745. self.page = self.start_page
  746. self._city_lookup_cache = None
  747. self._city_lookup_path = ""
  748. self.scheduler = scheduler
  749. self.report_data = {'task_id': self.task_id, 'platform': self.platform, 'username': self.scheduler.username}
  750. # 阶段 3:初始化运行时统计状态,这些状态会在主循环中持续更新。
  751. # 统计售罄数量
  752. self.sold_out_counts = 0
  753. # 程序启动时间
  754. self.program_start_time = self.app_start_time()
  755. # 统计商品数量
  756. # 最大量数据阈值
  757. self.max_counts = 0
  758. # 统计点击商品的次数
  759. self.click_counts = 0
  760. # 商品在列表的位置
  761. self.search_key_loc = 0
  762. # finish_reported 用来保证无论走哪个退出分支,只向调度系统上报一次结束状态。
  763. self.finish_reported = False
  764. # oss配置
  765. self.oss_config = {
  766. "access_key_id": Config.access_key_id,
  767. "access_key_secret": Config.access_key_secret,
  768. "endpoint": Config.endpoint, # 例: oss-cn-beijing.aliyuncs.com
  769. "bucket_name": Config.bucket_name,
  770. "oss_prefix": Config.oss_prefix # OSS中存放截图的前缀(虚拟文件夹)
  771. }
  772. self.address_region_index = self.build_address_region_index(_DEFAULT_PATH)
  773. # 异常处理
  774. def wr_re(self, mod, device_id, sort=None, page=None):
  775. # 功能:读写或删除本地进度文件,给断点续跑保留入口。
  776. file_path = f'./ycwj/{device_id}_{self.title_key}.txt'
  777. if mod == "写":
  778. try:
  779. data = {
  780. "page": page if page else "",
  781. "sort": sort if sort else "",
  782. }
  783. os.makedirs(os.path.dirname(file_path), exist_ok=True)
  784. with open(file_path, 'w', encoding='utf-8') as f:
  785. json.dump(data, f, ensure_ascii=False, indent=2)
  786. print(f"进度保存成功:{sort},{page}页")
  787. except Exception as e:
  788. print("保存进度失败")
  789. elif mod == "读":
  790. try:
  791. if not os.path.exists(file_path):
  792. return None
  793. with open(file_path, 'r', encoding='utf-8') as f:
  794. data = json.load(f)
  795. print(self.sort)
  796. # if self.sort and self.sort_key == 0:
  797. # self.li_or_lo(self.sort)
  798. if data['page'] != '':
  799. progress_page = int(data['page'])
  800. self.page = max(progress_page, self.start_page)
  801. self.scroll_to_target_page(self.page)
  802. else:
  803. return None
  804. return data
  805. except Exception as e:
  806. print(f"读取进度失败", e)
  807. return None
  808. elif mod == "删":
  809. try:
  810. if os.path.exists(file_path):
  811. os.remove(file_path)
  812. print(f"进度文件已删除:{file_path}")
  813. except Exception as e:
  814. print(f"删除进度文件失败:{e}")
  815. return None
  816. def clear_progress_file(self):
  817. # 功能:任务正常完成后清理断点进度文件。
  818. self.wr_re("删", self.device_id, self.sort)
  819. def is_max_count_reached(self):
  820. # 功能:判断当前采集数量是否达到任务设定上限。
  821. return bool(self.max_counts_limit and self.max_counts >= self.max_counts_limit)
  822. def scroll_to_target_page(self, target_page):
  823. # 功能:按目标页数执行固定次数滑动,用于恢复上次采集的大致位置。
  824. target_page = int(target_page or 0)
  825. if target_page <= 0:
  826. return
  827. self.loggerPdd.info(f"开始恢复历史进度,需向下滑动 {target_page} 页,请耐心等待...")
  828. # 这里按"页数约等于滑动次数"的经验规则恢复列表位置,宁可保守,不做复杂校准。
  829. for i in range(target_page):
  830. if (i + 1) % 10 == 0 or i == target_page - 1:
  831. self.loggerPdd.info(f"恢复进度滑动中... ({i + 1}/{target_page})")
  832. if self.d.xpath('//*[@text="向右滑动完成拼图"]').exists:
  833. self.loggerPdd.info("恢复进度中发现验证码,尝试解决=============")
  834. self.check_and_solve_captcha()
  835. screen_w, screen_h = self.get_screen_size()
  836. end_y = int(screen_h * 0.18)
  837. self.d.swipe(screen_w // 2, int(screen_h * 0.85), screen_w // 2, end_y, 0.4)
  838. time.sleep(0.5)
  839. # 排序
  840. def li_or_lo(self, key):
  841. # 功能:进入搜索结果页后切换价格排序方式。
  842. if key == "升序":
  843. self.sort_key += 1
  844. self.d.xpath('//*[@text="价格"]').click()
  845. n = self.d.xpath('//*[@text="总价低到高"]')
  846. if n.exists:
  847. n.click()
  848. time.sleep(self.get_sleep_time())
  849. if key == "降序":
  850. self.sort_key += 1
  851. self.d.xpath('//*[@text="价格"]').click()
  852. n = self.d.xpath('//*[@text="单粒价格低到高"]')
  853. if n:
  854. n.click()
  855. else:
  856. self.d.xpath('//*[@text="价格"]').click()
  857. # 返回列表页
  858. def back_to_list_page(self):
  859. # 功能:通过多次尝试返回键,尽量把页面恢复到商品列表页。
  860. try:
  861. for i in range(10):
  862. if self.distinct_target():
  863. return True
  864. if self.d(className='android.widget.EditText').exists and not self.d.xpath('//*[@text="筛选"]').exists:
  865. print('当前已进入搜索输入页,停止继续返回,交给上层重新进入搜索结果页')
  866. return False
  867. print(f'第{i}次尝试退回到列表页')
  868. self.swipe_back(1)
  869. except:
  870. if self.is_pdd_home_page():
  871. print('当前已回到首页,停止继续返回,交给上层重新进入搜索结果页')
  872. return False
  873. else:
  874. print('页面出错,没有退回到列表页')
  875. return False
  876. def get_drug_lis(self, idx):
  877. # 功能:根据当前页布局拿到可点击的商品卡片列表。
  878. # 这里区分 idx==0 和后续页面,是因为首屏与翻页后的 RecyclerView 层级不完全一致。
  879. # 提前检查验证码,防止在列表页遇到验证码导致返回空列表并触发重试滑动死循环
  880. if self.d.xpath('//*[@text="向右滑动完成拼图"]').exists:
  881. print("======出现验证码,尝试解决=======")
  882. self.loggerPdd.info("出现验证码,尝试解决=============")
  883. self.check_and_solve_captcha()
  884. drug_lis = []
  885. if idx == 0:
  886. drug_lis = self.d.xpath(
  887. '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.FrameLayout[2]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.support.v7.widget.RecyclerView[1]/android.widget.FrameLayout').all()
  888. else:
  889. for i in range(1, 6):
  890. drug_lis = self.d.xpath(
  891. f'/hierarchy/android.widget.FrameLayout[{i}]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.support.v7.widget.RecyclerView[1]/android.widget.FrameLayout').all()
  892. if drug_lis:
  893. break
  894. if drug_lis:
  895. return drug_lis
  896. fallback_xpaths = [
  897. '//android.support.v7.widget.RecyclerView/android.widget.FrameLayout',
  898. '//androidx.recyclerview.widget.RecyclerView/android.widget.FrameLayout',
  899. '//*[@class="android.support.v7.widget.RecyclerView"]/android.widget.FrameLayout',
  900. '//*[@class="androidx.recyclerview.widget.RecyclerView"]/android.widget.FrameLayout',
  901. ]
  902. for fallback_xpath in fallback_xpaths:
  903. drug_lis = self.d.xpath(fallback_xpath).all()
  904. if drug_lis:
  905. logging.info(f"get_drug_lis: fallback xpath 命中 {fallback_xpath},数量={len(drug_lis)}")
  906. break
  907. return drug_lis
  908. # 【新增】图片拼接辅助函数(垂直拼接两张截图)
  909. def merge_two_images(self, img1_path, img2_path, output_path):
  910. """
  911. 垂直拼接两张截图(统一宽度,高度累加)
  912. :param img1_path: 第一张图路径
  913. :param img2_path: 第二张图路径
  914. :param output_path: 拼接后输出路径
  915. """
  916. try:
  917. # 打开两张图片
  918. img1 = Image.open(img1_path)
  919. img2 = Image.open(img2_path)
  920. # 统一宽度(以较宽的图为准)
  921. max_width = max(img1.width, img2.width)
  922. img1 = img1.resize((max_width, int(img1.height * max_width / img1.width)), Image.Resampling.LANCZOS)
  923. img2 = img2.resize((max_width, int(img2.height * max_width / img2.width)), Image.Resampling.LANCZOS)
  924. # 创建拼接后的画布(垂直拼接)
  925. merge_height = img1.height + img2.height
  926. merge_img = Image.new('RGB', (max_width, merge_height))
  927. merge_img.paste(img1, (0, 0))
  928. merge_img.paste(img2, (0, img1.height))
  929. # 保存拼接后的图片
  930. merge_img.save(output_path, quality=95)
  931. self.loggerPdd.info(f"✅ 图片拼接完成:{output_path}")
  932. return True
  933. except Exception as e:
  934. self.loggerPdd.error(f"❌ 图片拼接失败:{str(e)}")
  935. return False
  936. # 【新增】图片压缩辅助函数(基于cv2,控制文件大小)
  937. def compress_image(self, input_path, output_path, target_size_kb=100, max_quality=80, min_quality=20):
  938. """
  939. 压缩图片到指定大小(默认100KB以内),先缩尺寸再调质量
  940. :param input_path: 原始图片路径
  941. :param output_path: 压缩后输出路径
  942. :param target_size_kb: 目标大小(KB),默认100
  943. :param max_quality: 初始最大质量(1-100)
  944. :param min_quality: 最低质量(避免过度模糊)
  945. :return: bool: 压缩是否成功
  946. """
  947. try:
  948. # 1. 读取原始图片
  949. img = cv2.imread(input_path)
  950. if img is None:
  951. self.loggerPdd.error(f"❌ 读取图片失败:{input_path}")
  952. return False
  953. # 2. 先按比例缩小尺寸(核心:降低分辨率)
  954. height, width = img.shape[:2]
  955. # 先缩放到原尺寸的70%(可根据需要调整,比如60%/50%)
  956. scale = 0.7 # 缩放比例,越小文件越小(0.7=70%尺寸)
  957. new_width = int(width * scale)
  958. new_height = int(height * scale)
  959. img_resized = cv2.resize(
  960. img, (new_width, new_height),
  961. interpolation=cv2.INTER_AREA # 缩小用INTER_AREA更清晰
  962. )
  963. # 3. 动态调整质量,直到文件≤target_size_kb或达到最低质量
  964. quality = max_quality
  965. ext = os.path.splitext(output_path)[1].lower()
  966. encode_param = [cv2.IMWRITE_JPEG_QUALITY, quality]
  967. while quality >= min_quality:
  968. # 保存图片
  969. cv2.imwrite(output_path, img_resized, encode_param)
  970. # 检测文件大小
  971. file_size = os.path.getsize(output_path) / 1024 # 转KB
  972. self.loggerPdd.info(f"🔍 质量{quality},文件大小:{file_size:.2f}KB")
  973. # 达标则退出循环
  974. if file_size <= target_size_kb:
  975. self.loggerPdd.info(f"✅ 图片压缩完成:{output_path}(最终质量:{quality},大小:{file_size:.2f}KB)")
  976. return True
  977. # 未达标则降低质量(每次降5,可调整步长)
  978. quality -= 5
  979. encode_param[1] = quality
  980. # 若到最低质量仍超标,再缩小尺寸(兜底)
  981. self.loggerPdd.warning(f"⚠️ 最低质量{min_quality}仍超标,再次缩小尺寸到50%")
  982. new_width_2 = int(new_width * 0.5)
  983. new_height_2 = int(new_height * 0.5)
  984. img_resized_2 = cv2.resize(img_resized, (new_width_2, new_height_2), cv2.INTER_AREA)
  985. encode_param[1] = min_quality
  986. cv2.imwrite(output_path, img_resized_2, encode_param)
  987. final_size = os.path.getsize(output_path) / 1024
  988. self.loggerPdd.info(f"✅ 兜底压缩完成:{output_path}(质量:{min_quality},大小:{final_size:.2f}KB)")
  989. return True
  990. except Exception as e:
  991. self.loggerPdd.error(f"❌ 图片压缩失败:{str(e)}")
  992. return False
  993. # 从商品顶部直接慢速向下滚动截长图 → 堆叠拼接 → 压缩 → 上传OSS
  994. def screenshot_and_upload_oss(self, screenshot_desc='shop_name_screenhshot'):
  995. """
  996. 1. 从当前位置慢速向下滚动截屏 → 2. 堆叠拼接 → 3. 压缩 → 4. 上传OSS → 5. 清理本地文件
  997. :param screenshot_desc: 截图描述(用于OSS文件名)
  998. :return: oss_url: 上传后的OSS地址 | None: 失败
  999. """
  1000. timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
  1001. unique_id = uuid.uuid4().hex[:8]
  1002. base_name = f"{screenshot_desc}_{self.device_id}_{timestamp}_{unique_id}"
  1003. # 定义临时文件路径
  1004. scroll_screenshot_path = f"./{base_name}_scroll.png" # 滚动长图
  1005. compress_path = f"./{base_name}_compress.jpg" # 压缩后的图
  1006. # 初始化OSS客户端(若未初始化)
  1007. if not hasattr(self, 'oss_bucket'):
  1008. self.oss_auth = oss2.Auth(
  1009. self.oss_config["access_key_id"],
  1010. self.oss_config["access_key_secret"]
  1011. )
  1012. self.oss_bucket = oss2.Bucket(
  1013. self.oss_auth,
  1014. self.oss_config["endpoint"],
  1015. self.oss_config["bucket_name"]
  1016. )
  1017. else:
  1018. print("oss初始化完毕")
  1019. try:
  1020. # ===================== 步骤1:滚动截屏 =====================
  1021. self.loggerPdd.info("📸 开始滚动截屏...")
  1022. try:
  1023. screenshot_image = self._scroll_screenshot()
  1024. screenshot_image.save(scroll_screenshot_path)
  1025. except Exception as e:
  1026. self.loggerPdd.error(f"滚动截图失败:{str(e)}")
  1027. return None
  1028. self.loggerPdd.info(f"✅ 滚动截图完成:{scroll_screenshot_path}")
  1029. # ===================== 步骤2:压缩图片 =====================
  1030. if not self.compress_image(scroll_screenshot_path, compress_path, target_size_kb=100):
  1031. raise Exception("图片压缩失败")
  1032. # ===================== 步骤3:上传压缩后的图片到OSS =====================
  1033. oss_file_path = f"{self.oss_config['oss_prefix']}/{os.path.basename(compress_path)}"
  1034. # 上传文件到OSS
  1035. self.oss_bucket.put_object_from_file(oss_file_path, compress_path)
  1036. # 生成OSS访问URL
  1037. oss_url = f"https://{self.oss_config['bucket_name']}.{self.oss_config['endpoint']}/{oss_file_path}"
  1038. self.loggerPdd.info(f"🚀 OSS上传成功:{oss_url}")
  1039. return oss_url
  1040. except Exception as e:
  1041. self.loggerPdd.error(f"❌ 截图/压缩/上传OSS失败:{str(e)}")
  1042. return None
  1043. finally:
  1044. # ===================== 步骤4:清理所有本地临时文件 =====================
  1045. for file_path in [scroll_screenshot_path, compress_path]:
  1046. if os.path.exists(file_path):
  1047. try:
  1048. os.remove(file_path)
  1049. self.loggerPdd.info(f"🗑️ 清理临时文件:{file_path}")
  1050. except Exception as e:
  1051. self.loggerPdd.warning(f"⚠️ 清理临时文件失败 {file_path}:{str(e)}")
  1052. def _merge_screenshots(self, screens):
  1053. """
  1054. 不做重叠裁剪,直接垂直堆叠
  1055. """
  1056. if len(screens) == 1:
  1057. return screens[0].convert('RGB')
  1058. rgb_screens = [s.convert('RGB') for s in screens]
  1059. total_width = rgb_screens[0].width
  1060. total_height = sum(s.height for s in rgb_screens)
  1061. merged_img = Image.new('RGB', (total_width, total_height))
  1062. y_offset = 0
  1063. for img in rgb_screens:
  1064. merged_img.paste(img, (0, y_offset))
  1065. y_offset += img.height
  1066. self.loggerPdd.info(f"✅ 拼接完成,尺寸: {total_width} x {total_height}")
  1067. return merged_img
  1068. def _scroll_screenshot(self, scroll_times=1):
  1069. """
  1070. 1. 截第一张(当前位置)
  1071. 2. 如果「进店」已经在首屏 → 直接返回,不滚动
  1072. 3. 否则最多滚动 scroll_times 次(默认 1 次),到「进店」即停止
  1073. 4. 堆叠拼接
  1074. """
  1075. self.loggerPdd.info(f"📸 滚动截图开始(最多 {scroll_times} 次滚动)...")
  1076. w, h = self.d.window_size()
  1077. # 第1张:当前位置
  1078. screen_list = [self.d.screenshot()]
  1079. self.loggerPdd.info(f" 截取第 1 张,尺寸: {screen_list[0].width}x{screen_list[0].height}")
  1080. # 首屏已有「进店」→ 直接返回,不用滚
  1081. if self.d(textContains='进店').exists or self.d(textStartsWith='进店').exists:
  1082. self.loggerPdd.info(f"✅ 「进店」已在首屏,不滚动")
  1083. return self._merge_screenshots(screen_list)
  1084. for i in range(scroll_times):
  1085. self.d.swipe(w // 2, int(h * 0.85), w // 2, int(h * 0.15),
  1086. duration=random.uniform(0.8, 1.5))
  1087. time.sleep(random.uniform(2.0, 4.0))
  1088. screen_list.append(self.d.screenshot())
  1089. self.loggerPdd.info(f" 截取第 {i + 2} 张")
  1090. if self.d(textContains='进店').exists or self.d(textStartsWith='进店').exists:
  1091. self.loggerPdd.info(f"✅ 已到达「进店」位置,停止滚动")
  1092. break
  1093. return self._merge_screenshots(screen_list)
  1094. # 代码运行那时候的时间
  1095. def app_current_time(self):
  1096. # 功能:返回当前时刻的格式化时间字符串,主要用于日志打印。
  1097. return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  1098. def slide_link(self):
  1099. # 功能:在分享面板中横向滑动,把"复制链接"附近的目标入口滑到可见区域。
  1100. value_tag = None
  1101. if self.d.xpath('//*[@text="微信"]').exists:
  1102. value_tag = self.d.xpath('//*[@text="微信"]').info['bounds']
  1103. start_x = value_tag['right'] + 20
  1104. end_x = max(start_x - 280, 30)
  1105. self.d.swipe(start_x, value_tag['top'], end_x, value_tag['top'], 0.3)
  1106. if self.d.xpath('//*[@text="复制链接"]').exists:
  1107. return
  1108. if self.d.xpath('//*[@text="朋友圈"]').exists:
  1109. value_tag = self.d.xpath('//*[@text="朋友圈"]').info['bounds']
  1110. start_x = value_tag['right'] + 20
  1111. end_x = max(start_x - 280, 30)
  1112. self.d.swipe(start_x, value_tag['top'], end_x, value_tag['top'], 0.3)
  1113. if self.d.xpath('//*[@text="复制链接"]').exists:
  1114. return
  1115. if self.d.xpath('//*[@text="QQ好友"]').exists:
  1116. value_tag = self.d.xpath('//*[@text="QQ好友"]').info['bounds']
  1117. start_x = value_tag['right'] + 20
  1118. end_x = max(start_x - 280, 30)
  1119. self.d.swipe(start_x, value_tag['top'], end_x, value_tag['top'], 0.3)
  1120. if self.d.xpath('//*[@text="复制链接"]').exists:
  1121. return
  1122. def app_start_time(self):
  1123. """
  1124. 获取app启动时间
  1125. :return:
  1126. """
  1127. return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  1128. def stop_app(self):
  1129. # 功能:停止拼多多 App,并等待设备状态稳定下来。
  1130. self.d.app_stop(self.package_name)
  1131. time.sleep(5)
  1132. def start_app(self):
  1133. # 功能:启动拼多多 App,并预留加载时间。
  1134. self.d.app_start(self.package_name)
  1135. time.sleep(5)
  1136. def restart_app(self):
  1137. """
  1138. 重启app
  1139. :return:
  1140. """
  1141. self.stop_app()
  1142. # 这里先调用 stop_app(),是为了清掉上一次运行残留的页面状态;
  1143. # 后续 start_app() 依赖应用已经被完全拉起,搜索流程才能从稳定起点开始。
  1144. self.start_app()
  1145. def is_pdd_package_foreground(self):
  1146. """只按包名判断是否在拼多多前台,不要求页面锚点可见。"""
  1147. try:
  1148. current_app = self.d.app_current() or {}
  1149. current_package = current_app.get("package", "")
  1150. return current_package == self.package_name
  1151. except Exception:
  1152. return False
  1153. def is_pdd_home_page(self):
  1154. """判断是否在拼多多主页面(底部主导航可见)。"""
  1155. try:
  1156. home_anchors = ["首页", "分类", "多多视频", "聊天", "我的", "个人中心"]
  1157. # 极速原生查询,避免 xpath 导致 Dump 完整 XML 的高额耗时
  1158. hit_count = sum(1 for text in home_anchors if self.d(text=text).exists)
  1159. return hit_count >= 3
  1160. except Exception:
  1161. return False
  1162. def back_to_pdd_home_page(self, max_back_times=2):
  1163. """
  1164. 尝试通过返回键回到拼多多主页面。
  1165. 按你的要求最多执行两次返回。
  1166. """
  1167. if not self.is_pdd_package_foreground():
  1168. return False
  1169. for _ in range(max_back_times):
  1170. if self.is_pdd_home_page():
  1171. break
  1172. self.d.press('back')
  1173. time.sleep(self.get_sleep_time())
  1174. return self.is_pdd_home_page()
  1175. def ensure_home_before_task_end(self, max_rounds=6):
  1176. """
  1177. 任务结束前做收尾:持续回退直到回到拼多多主页面。
  1178. 每轮最多回退两次,总轮数可配置,避免无限循环。
  1179. """
  1180. try:
  1181. if not self.is_pdd_package_foreground():
  1182. logging.info("ensure_home_before_task_end: 拼多多不在前台,先拉起APP")
  1183. self.start_app()
  1184. time.sleep(self.get_sleep_time())
  1185. # 有bug,回到拼多多主页后不会继续执行
  1186. for round_idx in range(max_rounds):
  1187. if self.back_to_pdd_home_page(max_back_times=2):
  1188. logging.info(
  1189. f"ensure_home_before_task_end: 已回到拼多多主页面(轮次 {round_idx + 1}/{max_rounds})"
  1190. )
  1191. return True
  1192. logging.info(
  1193. f"ensure_home_before_task_end: 第 {round_idx + 1}/{max_rounds} 轮未到主页面,继续回退"
  1194. )
  1195. home_ready = self.is_pdd_home_page()
  1196. logging.info(f"ensure_home_before_task_end: 收尾结束,主页面状态={home_ready}")
  1197. return home_ready
  1198. except Exception as e:
  1199. logging.info(f"ensure_home_before_task_end: 执行异常: {e}")
  1200. return False
  1201. def focus_search_box(self, max_attempts=6):
  1202. """把搜索框点出来,为 enter_target_page 提供稳定起点。"""
  1203. search_entry_candidates = [
  1204. '//*[contains(@text, "搜索") and not(contains(@text, "拍照"))]',
  1205. '//*[contains(@content-desc, "搜索") and not(contains(@content-desc, "拍照"))]',
  1206. '//*[@resource-id="android:id/content"]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]'
  1207. ]
  1208. for attempt in range(max_attempts):
  1209. if self.d(className='android.widget.EditText').wait(timeout=1):
  1210. return True
  1211. clicked = False
  1212. for entry_xpath in search_entry_candidates:
  1213. if self.d.xpath(entry_xpath).exists:
  1214. self.d.xpath(entry_xpath).click()
  1215. clicked = True
  1216. time.sleep(self.get_sleep_time())
  1217. if self.d(className='android.widget.EditText').wait(timeout=1):
  1218. return True
  1219. if not clicked:
  1220. # 兜底点顶部搜索区域,兼容节点层级变化。
  1221. try:
  1222. screen_width = self.d.info.get('displayWidth', 1080)
  1223. screen_height = self.d.info.get('displayHeight', 2400)
  1224. self.d.click(screen_width // 2, int(screen_height * 0.08))
  1225. time.sleep(self.get_sleep_time())
  1226. if self.d(className='android.widget.EditText').wait(timeout=1):
  1227. return True
  1228. except Exception as click_error:
  1229. logging.info(f"focus_search_box: 顶部点击失败: {click_error}")
  1230. if not self.is_pdd_package_foreground():
  1231. logging.info("focus_search_box: 检测到拼多多不在前台,重新拉起APP")
  1232. self.start_app()
  1233. time.sleep(self.get_sleep_time())
  1234. logging.info(f"focus_search_box: 第 {attempt + 1}/{max_attempts} 次未定位到搜索框")
  1235. return False
  1236. def focus_search_box_after_restart(self, max_attempts=6):
  1237. """兼容旧调用,统一复用 focus_search_box。"""
  1238. return self.focus_search_box(max_attempts=max_attempts)
  1239. def prepare_entry_before_enter_target(self, force_restart=False):
  1240. """
  1241. 统一入口准备:
  1242. 1) 按需重启;
  1243. 2) 回退最多两次到主页面;
  1244. 3) 点击出搜索框;
  1245. 4) 后续交给 enter_target_page 执行输入和搜索。
  1246. """
  1247. if force_restart:
  1248. self.restart_app()
  1249. time.sleep(self.get_sleep_time())
  1250. elif not self.is_pdd_package_foreground():
  1251. logging.info("prepare_entry: 当前拼多多不在前台,改为先重启")
  1252. self.restart_app()
  1253. time.sleep(self.get_sleep_time())
  1254. home_ready = self.back_to_pdd_home_page(max_back_times=4)
  1255. logging.info(f"prepare_entry: 回到主页面结果={home_ready}")
  1256. search_ready = self.focus_search_box(max_attempts=6)
  1257. logging.info(f"prepare_entry: 搜索框定位结果={search_ready}")
  1258. return search_ready
  1259. def is_pdd_foreground(self):
  1260. """仅当"前台包名是拼多多 + 页面可见"时返回 True。"""
  1261. try:
  1262. current_app = self.d.app_current() or {}
  1263. current_package = current_app.get("package", "")
  1264. current_activity = current_app.get("activity", "")
  1265. device_info = self.d.info or {}
  1266. screen_on = bool(device_info.get("screenOn", True))
  1267. print(
  1268. f"foreground check -> package={current_package}, "
  1269. f"activity={current_activity}, screen_on={screen_on}"
  1270. )
  1271. if not screen_on:
  1272. return False
  1273. if current_package != self.package_name:
  1274. return False
  1275. # 仅包名命中仍不够:要求当前页面能看到拼多多关键锚点,避免"后台残留包名"误判。
  1276. visible_anchor_xpaths = [
  1277. '//*[contains(@text, "搜索")]',
  1278. '//*[@text="首页"]',
  1279. '//*[@text="多多视频"]',
  1280. '//*[@text="聊天"]',
  1281. '//*[@text="我的"]',
  1282. '//*[@text="个人中心"]',
  1283. '//*[@text="进店"]',
  1284. '//*[starts-with(@text, "商品参数")]',
  1285. ]
  1286. for anchor_xpath in visible_anchor_xpaths:
  1287. if self.d.xpath(anchor_xpath).exists:
  1288. return True
  1289. # 商品详情页有时锚点不稳定,放一个输入框兜底判断。
  1290. if self.d(className='android.widget.EditText').exists:
  1291. return True
  1292. print("package 命中拼多多,但页面锚点不可见,按后台处理并触发重启")
  1293. return False
  1294. except Exception as e:
  1295. print(f"failed to read foreground package/page state: {e}")
  1296. return False
  1297. def prepare_search_in_current_app(self):
  1298. """Reuse current PDD page: swipe first, then clear and search without app restart."""
  1299. print("pdd is in foreground, reuse app without restart")
  1300. # Strong swipe-up first, as requested.
  1301. for _ in range(2):
  1302. self.d.swipe_ext("up", scale=0.45)
  1303. time.sleep(0.8)
  1304. search_entry_candidates = [
  1305. '//*[contains(@text, "\u641c\u7d22")]',
  1306. '//*[contains(@content-desc, "\u641c\u7d22")]',
  1307. '//*[@resource-id="android:id/content"]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]'
  1308. '/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]'
  1309. '/android.widget.LinearLayout[1]',
  1310. ]
  1311. # Try to get search input directly; if not found, click possible search entries.
  1312. if not self.d(className='android.widget.EditText').wait(timeout=2):
  1313. for entry_xpath in search_entry_candidates:
  1314. if self.d.xpath(entry_xpath).exists:
  1315. self.d.xpath(entry_xpath).click()
  1316. time.sleep(self.get_sleep_time())
  1317. if self.d(className='android.widget.EditText').wait(timeout=2):
  1318. break
  1319. # Some list pages need one back press before search input appears.
  1320. if not self.d(className='android.widget.EditText').wait(timeout=2):
  1321. self.d.press("back")
  1322. time.sleep(self.get_sleep_time())
  1323. # Last fallback: use existing enter_target_page flow.
  1324. if not self.d(className='android.widget.EditText').wait(timeout=2):
  1325. print("search input not found after swipe, fallback to enter_target_page")
  1326. self.enter_target_page()
  1327. return
  1328. self.d(className='android.widget.EditText').click()
  1329. time.sleep(self.get_sleep_time())
  1330. clear_xpath_candidates = [
  1331. '//*[@text="\u6e05\u9664"]',
  1332. '//*[@text="\u6e05\u7a7a"]',
  1333. '//*[@content-desc="\u6e05\u9664"]',
  1334. '//*[@content-desc="\u6e05\u7a7a"]',
  1335. ]
  1336. for clear_xpath in clear_xpath_candidates:
  1337. if self.d.xpath(clear_xpath).exists:
  1338. self.d.xpath(clear_xpath).click()
  1339. time.sleep(0.5)
  1340. print("clicked clear button")
  1341. break
  1342. self.d.send_keys(self.search_key, clear=True)
  1343. time.sleep(self.get_sleep_time())
  1344. if self.d.xpath('//*[@text="\u641c\u7d22"]').exists:
  1345. self.d.xpath('//*[@text="\u641c\u7d22"]').click()
  1346. else:
  1347. self.d.press("enter")
  1348. time.sleep(self.get_sleep_time())
  1349. @staticmethod
  1350. def get_sleep_time():
  1351. # 功能:生成短随机等待时间,减少固定节奏操作带来的页面未加载或风控风险。
  1352. return random.uniform(0.5, 1.0)
  1353. # return random.randint(5, 8)
  1354. def get_screen_size(self):
  1355. """返回 (width, height) 屏幕尺寸,首次调用后缓存,兼容不同分辨率设备"""
  1356. if not hasattr(self, '_cached_screen_w'):
  1357. info = self.d.info
  1358. self._cached_screen_w = info.get('displayWidth', 720)
  1359. self._cached_screen_h = info.get('displayHeight', 1640)
  1360. return self._cached_screen_w, self._cached_screen_h
  1361. @staticmethod
  1362. def get_current_date():
  1363. # 功能:返回当前采集日期,作为去重和落库字段使用。
  1364. return datetime.datetime.now().strftime('%Y/%m/%d')
  1365. @staticmethod
  1366. def _normalize_rule_list(value):
  1367. # 功能:把单值或集合统一归一成非空字符串列表,供过滤逻辑直接复用。
  1368. if value is None:
  1369. return []
  1370. if isinstance(value, (list, tuple, set)):
  1371. raw_values = value
  1372. else:
  1373. raw_values = [value]
  1374. result = []
  1375. for item in raw_values:
  1376. item_str = str(item).strip()
  1377. if item_str:
  1378. result.append(item_str)
  1379. return result
  1380. @staticmethod
  1381. def _normalize_match_text(value):
  1382. # 功能:把待匹配文本做去空白和小写归一,减少页面文案格式差异带来的误判。
  1383. return re.sub(r'\s+', '', str(value or '')).lower()
  1384. @staticmethod
  1385. def _normalize_region_name(name, level):
  1386. text = re.sub(r"\s+", "", str(name or ""))
  1387. text = text.replace(" ", "")
  1388. if not text:
  1389. return ""
  1390. if level == "province":
  1391. province_alias = {
  1392. "内蒙古自治区": "内蒙古",
  1393. "广西壮族自治区": "广西",
  1394. "宁夏回族自治区": "宁夏",
  1395. "新疆维吾尔自治区": "新疆",
  1396. "西藏自治区": "西藏",
  1397. "香港特别行政区": "香港",
  1398. "澳门特别行政区": "澳门",
  1399. }
  1400. if text in province_alias:
  1401. return province_alias[text]
  1402. for suffix in ("特别行政区", "维吾尔自治区", "回族自治区", "壮族自治区", "自治区", "省", "市"):
  1403. if text.endswith(suffix) and len(text) > len(suffix):
  1404. return text[:-len(suffix)]
  1405. return text
  1406. city_alias = {
  1407. "北京市": "北京",
  1408. "天津市": "天津",
  1409. "上海市": "上海",
  1410. "重庆市": "重庆",
  1411. }
  1412. if text in city_alias:
  1413. return city_alias[text]
  1414. for suffix in ("自治州", "地区", "盟", "州", "市"):
  1415. if text.endswith(suffix) and len(text) > len(suffix):
  1416. return text[:-len(suffix)]
  1417. return text
  1418. def _load_city_lookup(self):
  1419. if self._city_lookup_cache is not None:
  1420. return self._city_lookup_cache
  1421. base_dir = os.path.dirname(os.path.abspath(__file__))
  1422. candidate_paths = [
  1423. os.path.join(base_dir, "spiders", "pdd", "city.json"),
  1424. os.path.join(base_dir, "city.json"),
  1425. os.path.join(base_dir, "process_shop", "city.json"),
  1426. ]
  1427. city_data = []
  1428. loaded_path = ""
  1429. for path in candidate_paths:
  1430. if not os.path.exists(path):
  1431. continue
  1432. try:
  1433. with open(path, "r", encoding="utf-8") as f:
  1434. candidate_data = json.load(f)
  1435. if isinstance(candidate_data, list):
  1436. city_data = candidate_data
  1437. loaded_path = path
  1438. break
  1439. except Exception as exc:
  1440. logging.warning(f"加载 city.json 失败: {path}, error={exc}")
  1441. province_to_id = {}
  1442. province_city_to_id = {}
  1443. global_city_matches = {}
  1444. for province in city_data:
  1445. province_norm = self._normalize_region_name(province.get("name", ""), "province")
  1446. province_id = parse_optional_int(province.get("id"), 0) or 0
  1447. if province_norm and province_id and province_norm not in province_to_id:
  1448. province_to_id[province_norm] = province_id
  1449. city_map = {}
  1450. for city in province.get("sons", []) or []:
  1451. city_norm = self._normalize_region_name(city.get("name", ""), "city")
  1452. city_id = parse_optional_int(city.get("id"), 0) or 0
  1453. if city_norm and city_id and city_norm not in city_map:
  1454. city_map[city_norm] = city_id
  1455. if city_norm and city_id and province_id:
  1456. global_city_matches.setdefault(city_norm, []).append((province_id, city_id, province_norm))
  1457. if province_norm and province_norm not in province_city_to_id:
  1458. province_city_to_id[province_norm] = city_map
  1459. if loaded_path:
  1460. logging.info(f"已加载 city.json: {loaded_path}")
  1461. else:
  1462. logging.warning("未找到可用 city.json,省市ID将回填为 0")
  1463. self._city_lookup_path = loaded_path
  1464. self._city_lookup_cache = {
  1465. "province_to_id": province_to_id,
  1466. "province_city_to_id": province_city_to_id,
  1467. "global_city_matches": global_city_matches,
  1468. }
  1469. return self._city_lookup_cache
  1470. def _resolve_region_ids(self, province_name, city_name):
  1471. city_id, province_id, _, _ = get_city(city_name or province_name)
  1472. return province_id or 0, city_id or 0
  1473. def _match_any_keyword(self, text, keywords):
  1474. # 功能:判断目标文本是否命中任一过滤词;过滤词为空时直接放行。
  1475. keyword_list = self._normalize_rule_list(keywords)
  1476. if not keyword_list:
  1477. # 没有配置过滤词时默认放行,让调用方只在需要时开启细筛。
  1478. return True
  1479. normalized_text = self._normalize_match_text(text)
  1480. return any(self._normalize_match_text(keyword) in normalized_text for keyword in keyword_list)
  1481. def _get_brand_match_keywords(self):
  1482. brand_keywords = self._normalize_rule_list(self.brand)
  1483. for brand in list(brand_keywords):
  1484. brand_keywords.extend(self._normalize_rule_list(BRAND_ALIAS_MAP.get(brand, [])))
  1485. return list(dict.fromkeys(brand_keywords))
  1486. def is_link_spec_useful(self, product_title, specifications=''):
  1487. # 功能:判断标题或说明书规格里是否包含目标品规。
  1488. if not self.spec_list:
  1489. return True
  1490. title_text = self._normalize_match_text(product_title)
  1491. spec_text = self._normalize_match_text(specifications)
  1492. for spec in self.spec_list:
  1493. normalized_spec = self._normalize_match_text(spec)
  1494. if normalized_spec in title_text or normalized_spec in spec_text:
  1495. return True
  1496. return False
  1497. def is_link_useful(self, product_title, specifications=''):
  1498. # 功能:统一做标题、品牌、规格三层过滤,尽量在早期就排除无关商品。
  1499. if not self._match_any_keyword(product_title, self.title_key):
  1500. print(f"当前商品名称:{product_title} 不包含{self.title_key}关键字")
  1501. return False
  1502. brand_keywords = self._get_brand_match_keywords()
  1503. if not self._match_any_keyword(product_title, brand_keywords):
  1504. print(f"当前商品名称:{product_title} 不包含{brand_keywords}品牌")
  1505. return False
  1506. if not self.is_link_spec_useful(product_title, specifications):
  1507. print(f"当前商品名称:{product_title} 不包含{self.spec_list}品规")
  1508. return False
  1509. return True
  1510. def remove_watermark(self, img_path):
  1511. # 功能:弱化截图中的水印或遮罩,提升后续 OCR 识别成功率。
  1512. """
  1513. 图片去水印(将水印部分变成白色背景)并将数据转化为二进制数据
  1514. :param img_path: 图片路径
  1515. :return: 二进制图片数据
  1516. """
  1517. img = cv2.imdecode(np.fromfile(img_path, dtype=np.uint8), -1)
  1518. endswith = os.path.splitext(img_path)[1]
  1519. new = np.clip(1.4057577998008846 * img - 38.33089999653017, 0, 255).astype(np.uint8)
  1520. _, img_binary = cv2.imencode(endswith, new)
  1521. return img_binary
  1522. def get_license_info(self):
  1523. img_path = f"./shop_info_screenshot.png" # 截图地址
  1524. has_jindian = self.d.xpath('//*[@text="进店"]').exists
  1525. if has_jindian:
  1526. # 旧版:滚动直到"进店"接近顶部
  1527. for i in range(15):
  1528. if not self.d.xpath('//*[@text="进店"]').exists:
  1529. break
  1530. shop_bounds = self.d.xpath('//*[@text="进店"]').info['bounds']
  1531. element_top = shop_bounds['top']
  1532. print(f"进店到顶部的距离:{element_top}")
  1533. _, screen_h = self.get_screen_size()
  1534. if element_top <= int(screen_h * 0.30):
  1535. self.loggerPdd.info(f"🔍 第{i + 1}次上滑,「进店」上滑到最顶部")
  1536. break
  1537. self.d.swipe_ext("up", scale=0.2)
  1538. time.sleep(self.get_sleep_time())
  1539. else:
  1540. # 新版:点击底部"店铺"tab进入店铺页
  1541. self.loggerPdd.info('无进店按钮,通过底部店铺tab进入店铺页')
  1542. shop_tab = self.d.xpath('//*[@content-desc="店铺"]')
  1543. if not shop_tab.exists:
  1544. shop_tab = self.d.xpath('//*[@text="店铺"]')
  1545. if shop_tab.exists:
  1546. shop_tab.click()
  1547. time.sleep(2)
  1548. license_info = {}
  1549. # 新版 + 旧版:搜索资质证照图标(兼容 support.v7 和 androidx)
  1550. shop_license_xpaths = [
  1551. '(//android.support.v7.widget.RecyclerView[@resource-id="com.xunmeng.pinduoduo:id/pdd"]/android.widget.FrameLayout/android.view.ViewGroup/android.widget.ImageView[@resource-id="com.xunmeng.pinduoduo:id/pdd"])[2]',
  1552. '(//androidx.recyclerview.widget.RecyclerView[@resource-id="com.xunmeng.pinduoduo:id/pdd"]/android.widget.FrameLayout/android.view.ViewGroup/android.widget.ImageView[@resource-id="com.xunmeng.pinduoduo:id/pdd"])[2]',
  1553. ]
  1554. found_license = False
  1555. for xp in shop_license_xpaths:
  1556. if self.d.xpath(xp).exists:
  1557. print(f"存在资质图标: {xp[:60]}...")
  1558. self.d.xpath(xp).click()
  1559. found_license = True
  1560. break
  1561. if not found_license:
  1562. # 兜底:搜索 contentDescription 含"资质"或"证照"的 ImageView
  1563. for n in self.d.xpath('//android.widget.ImageView').all():
  1564. desc = (n.info.get('contentDescription') or '').strip()
  1565. if '资质' in desc or '证照' in desc:
  1566. n.click()
  1567. found_license = True
  1568. break
  1569. if found_license:
  1570. print(f"点击资质图标")
  1571. time.sleep(3)
  1572. self.d.screenshot(img_path)
  1573. self.loggerPdd.info(f"截图资质成功:{img_path}")
  1574. ocr_res = self.get_baidu_ocr_jgh_res(img_path)
  1575. if ocr_res:
  1576. for key in ocr_res:
  1577. if '企业名称' in key:
  1578. license_info['business_license_company'] = ocr_res[key]
  1579. if '经营地址' in key:
  1580. license_info['business_license_address'] = ocr_res[key]
  1581. if '注册地址' in key:
  1582. license_info['business_license_address'] = ocr_res[key]
  1583. if '信用代码' in key:
  1584. license_info['qualification_number'] = ocr_res[key]
  1585. if license_info.get('business_license_address'):
  1586. license_info['province'], license_info['city'] = self.infer_region_from_address(
  1587. license_info['business_license_address'])
  1588. # 从资质页返回
  1589. if self.d.xpath('//*[@content-desc="返回"]').exists:
  1590. self.d.xpath('//*[@content-desc="返回"]').click()
  1591. elif not has_jindian:
  1592. # 新版:从店铺页返回详情页
  1593. self.d.press('back')
  1594. else:
  1595. print(f"不存在shop资质")
  1596. # 新版:从店铺页返回
  1597. if not has_jindian and not found_license:
  1598. self.d.press('back')
  1599. time.sleep(self.get_sleep_time())
  1600. return license_info
  1601. @staticmethod
  1602. def _norm_region_name(name):
  1603. if name is None:
  1604. return ""
  1605. text = str(name).strip()
  1606. if not text:
  1607. return ""
  1608. return re.sub(r'(省|市|自治区|特别行政区)$', '', text)
  1609. def build_address_region_index(self, file_path):
  1610. pairs = []
  1611. try:
  1612. with open(file_path, 'r', encoding='utf-8') as f:
  1613. data = json.load(f)
  1614. for province in data:
  1615. p_name = str(province.get("name") or "").strip()
  1616. if not p_name:
  1617. continue
  1618. p_candidates = {p_name, self._norm_region_name(p_name)}
  1619. cities = province.get("sons") or []
  1620. if not cities:
  1621. pairs.append({
  1622. "province": p_name,
  1623. "city": "",
  1624. "province_candidates": p_candidates,
  1625. "city_candidates": set()
  1626. })
  1627. continue
  1628. for city in cities:
  1629. c_name = str(city.get("name") or "").strip()
  1630. c_candidates = {c_name, self._norm_region_name(c_name)} if c_name else set()
  1631. pairs.append({
  1632. "province": p_name,
  1633. "city": c_name,
  1634. "province_candidates": p_candidates,
  1635. "city_candidates": c_candidates
  1636. })
  1637. except Exception as e:
  1638. print(f"构建地址省市索引失败: {e}")
  1639. return pairs
  1640. def infer_region_from_address(self, address):
  1641. text = str(address or "").strip()
  1642. if not text:
  1643. return "", ""
  1644. for item in self.address_region_index:
  1645. if any(k and k in text for k in item["province_candidates"]):
  1646. city = item["city"]
  1647. if city and any(k and k in text for k in item["city_candidates"]):
  1648. return item["province"], city
  1649. for item in self.address_region_index:
  1650. if any(k and k in text for k in item["province_candidates"]):
  1651. return item["province"], item["city"]
  1652. return "", ""
  1653. def get_shop_name(self):
  1654. # 功能:优先从当前详情页直接提取店铺名,失败时再进入店铺页兜底提取。
  1655. """
  1656. 获取店铺名
  1657. 兼容多版本:进店锚点 → 文案搜索 → 底部店铺tab → 滚回顶部重试
  1658. :return:
  1659. """
  1660. try:
  1661. shop_keywords = ['大药房', '旗舰店', '专卖店', '药房', '药店', '官方店', '专营店', '健康']
  1662. xpath_v1 = '//*[@text="进店"]/preceding-sibling::android.widget.LinearLayout/android.widget.TextView'
  1663. xpath_v2 = '//*[@text="进店"]/preceding-sibling::android.view.ViewGroup/android.widget.LinearLayout/android.widget.TextView'
  1664. def _try_direct():
  1665. """尝试直接从详情页读取店铺名"""
  1666. if self.d.xpath(xpath_v1).exists:
  1667. return self.d.xpath(xpath_v1).text, 'v1进店锚点'
  1668. if self.d.xpath(xpath_v2).exists:
  1669. return self.d.xpath(xpath_v2).text, 'v2进店锚点'
  1670. for kw in shop_keywords:
  1671. for n in self.d.xpath(f'//android.widget.TextView[contains(@text, "{kw}")]').all():
  1672. txt = (n.info.get('text') or '').strip()
  1673. b = n.info.get('bounds', {})
  1674. if len(txt) < 25 and b.get('top', 0) > 300:
  1675. return txt, f'文案搜索({kw})'
  1676. return None, None
  1677. # 第一次尝试
  1678. shop_name, method = _try_direct()
  1679. if shop_name:
  1680. self.loggerPdd.info(f'获取到店铺名({method}): {shop_name}')
  1681. return shop_name
  1682. # 滚回顶部再试(截图等操作可能把页面滚走了)
  1683. self.loggerPdd.info('首轮未获取到店铺名,滚回顶部重试')
  1684. for _ in range(3):
  1685. self.d.swipe_ext("down", scale=0.5)
  1686. time.sleep(0.5)
  1687. shop_name, method = _try_direct()
  1688. if shop_name:
  1689. self.loggerPdd.info(f'获取到店铺名(滚回后-{method}): {shop_name}')
  1690. return shop_name
  1691. # 方式3:点击底部"店铺"tab进入店铺页
  1692. shop_tab = self.d.xpath('//*[@content-desc="店铺"]')
  1693. if not shop_tab.exists:
  1694. shop_tab = self.d.xpath('//*[@text="店铺"]')
  1695. if shop_tab.exists:
  1696. shop_tab.click()
  1697. time.sleep(2)
  1698. screen_width, screen_height = self.d.window_size()
  1699. for kw in shop_keywords:
  1700. for n in self.d.xpath(f'//android.widget.TextView[contains(@text, "{kw}")]').all():
  1701. txt = (n.info.get('text') or '').strip()
  1702. b = n.info.get('bounds', {})
  1703. if len(txt) < 25 and b.get('top', 0) < 600:
  1704. self.loggerPdd.info(f'获取到店铺名(店铺页): {txt}')
  1705. self.swipe_back(1)
  1706. return txt
  1707. # 3. 计算滑动坐标(屏幕防卡)
  1708. start_x = screen_width // 2 # 水平居中,稍微滑一下,顺便防蔽屏。
  1709. start_y = screen_height * 0.2 # 起点:屏幕20%高度,偏上方。
  1710. end_y = screen_height * 0.8 # 终点:屏幕80%高度,偏下方。
  1711. # 强制确保起点的y < 终点的y,作为下滑边界
  1712. start_y, end_y = min(start_y, end_y - 10), max(end_y, start_y + 10)
  1713. self.d.swipe(start_x, start_y, start_x, end_y)
  1714. self.swipe_back(1)
  1715. self.loggerPdd.info('所有方式均未获取到店铺名')
  1716. return ''
  1717. except Exception as e:
  1718. print(f'获取店铺名出错:{e}')
  1719. self.loggerPdd.error(f'获取店铺名出错:{e}')
  1720. return None
  1721. def save_to_shop_database(self, data):
  1722. # 功能:把当前商品采集结果落库;只有 commit 成功后才计入采集数量。
  1723. print(f'保存店铺数据到店铺数据库:{data}')
  1724. shop = str(data.get('shop') or '').strip()
  1725. if not shop:
  1726. print("保存店铺数据失败:shop 为空")
  1727. return False
  1728. # 数据库部分字段为 NOT NULL,统一做非空兜底,避免写入 None 触发 1048。
  1729. contact_address = str(data.get('contact_address') or '').strip()
  1730. qualification_number = str(data.get('qualification_number') or '').strip()
  1731. business_license_company = str(data.get('business_license_company') or '').strip()
  1732. business_license_address = str(data.get('business_license_address') or '').strip()
  1733. shop_url = str(data.get('store_url') or '').strip()
  1734. scrape_date = str(data.get('scrape_date') or self.get_current_date()).strip()
  1735. platform = str(data.get('platform') or '3').strip()
  1736. province = str(data.get('province_name') or data.get('province') or '').strip()
  1737. city = str(data.get('city_name') or data.get('city') or '').strip()
  1738. create_time = data.get('create_time') or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  1739. update_time = data.get('update_time') or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  1740. max_retries = 5
  1741. # 数据库偶发抖动时允许短重试,但只有 commit 成功后才算真正采集到一条数据。
  1742. for attempt in range(max_retries):
  1743. conn = None
  1744. try:
  1745. conn = get_mysql()
  1746. with conn.cursor() as cur:
  1747. add_sql = """
  1748. INSERT IGNORE INTO retrieve_scrape_shop_info (
  1749. shop, contact_address, qualification_number, business_license_company,
  1750. business_license_address, shop_url, scrape_date, platform,
  1751. province,city, create_time, update_time,company_id,shop_id
  1752. ) VALUES (
  1753. %s, %s, %s, %s, %s,
  1754. %s, %s, %s, %s, %s,
  1755. %s, %s,%s,%s
  1756. )
  1757. """
  1758. cur.execute(add_sql, (
  1759. shop,
  1760. contact_address,
  1761. qualification_number,
  1762. business_license_company,
  1763. business_license_address,
  1764. shop_url,
  1765. scrape_date,
  1766. platform,
  1767. province,
  1768. city,
  1769. create_time,
  1770. update_time,
  1771. shop,
  1772. shop,
  1773. ))
  1774. conn.commit()
  1775. self.shop_counts = getattr(self, 'shop_counts', 0) + 1
  1776. self.loggerPdd.info(
  1777. f"========== 设备 [{self.device_id}] 存入店铺库成功,当前该设备总计采集店铺: {self.shop_counts} 个 ==========")
  1778. return True
  1779. except Exception as e:
  1780. print(f'保存数据库异常 (尝试 {attempt + 1}/{max_retries}): {e}')
  1781. if conn:
  1782. conn.rollback()
  1783. conn.close()
  1784. if attempt == max_retries - 1:
  1785. print("达到最大重试次数,保存失败")
  1786. return False
  1787. time.sleep(2)
  1788. def save_to_database(self, data):
  1789. # 功能:把当前商品采集结果落库;只有 commit 成功后才计入采集数量。
  1790. print(f'保存数据到数据库:{data}')
  1791. max_retries = 5
  1792. # 数据库偶发抖动时允许短重试,但只有 commit 成功后才算真正采集到一条数据。
  1793. for attempt in range(max_retries):
  1794. conn = None
  1795. try:
  1796. conn = get_mysql()
  1797. with conn.cursor() as cur:
  1798. add_sql = """
  1799. INSERT IGNORE INTO retrieve_scrape_data (
  1800. enterprise_id, platform_id, platform_item_id, province_id, city_id,
  1801. province_name, city_name, area_info, product_brand, product_name, product_specs, search_name,
  1802. collect_config_info, one_box_price, manufacture_date, expiry_date, manufacturer, approval_number,
  1803. is_sold_out, online_posting_count, continuous_listing_count, link_url,
  1804. store_name, store_url, shipment_province_id, shipment_province_name,
  1805. shipment_city_id, shipment_city_name, company_name, qualification_number,
  1806. scrape_date, min_price, number, sales, inventory, snapshot_url,collect_equipment_account_id,insert_time,update_time,collect_round,collect_region_id,task_id,
  1807. shop_id, company_id
  1808. ) VALUES (
  1809. %s, %s, %s, %s, %s,
  1810. %s, %s, %s, %s, %s, %s, %s,
  1811. %s, %s, %s, %s, %s, %s,
  1812. %s, %s, %s, %s,
  1813. %s, %s, %s, %s,
  1814. %s, %s, %s, %s,%s,%s,%s,
  1815. %s, %s, %s, %s, %s, %s, %s, %s, %s,
  1816. %s, %s
  1817. )
  1818. """
  1819. cur.execute(add_sql, (
  1820. data['enterprise_id'],
  1821. data['platform_id'],
  1822. str(data['min_price']) + data['store_name'] + data['product_name'],
  1823. data['province_id'],
  1824. data['city_id'],
  1825. data['province_name'],
  1826. data['city_name'],
  1827. data['area_info'],
  1828. data['product_brand'],
  1829. data['product_name'],
  1830. data['product_specs'],
  1831. data['search_name'],
  1832. data['collect_config_info'],
  1833. data['one_box_price'],
  1834. data['manufacture_date'],
  1835. data['expiry_date'],
  1836. data['manufacturer'],
  1837. data['approval_number'],
  1838. data['is_sold_out'],
  1839. data['online_posting_count'],
  1840. data['continuous_listing_count'],
  1841. data['link_url'],
  1842. data['store_name'],
  1843. data['store_url'],
  1844. data['shipment_province_id'],
  1845. data['shipment_province_name'],
  1846. data['shipment_city_id'],
  1847. data['shipment_city_name'],
  1848. data['company_name'],
  1849. data['qualification_number'],
  1850. data['scrape_date'],
  1851. data['min_price'],
  1852. data['number'],
  1853. data['sales'],
  1854. data['inventory'],
  1855. data['snapshot_url'],
  1856. data.get('collect_equipment_account_id', 0),
  1857. data['insert_time'],
  1858. data['update_time'],
  1859. data['collect_round'],
  1860. data['collect_region_id'],
  1861. data.get('task_id') or 0,
  1862. data['store_name'],
  1863. data['store_name'],
  1864. ))
  1865. conn.commit()
  1866. self.max_counts += 1
  1867. global GLOBAL_DEVICE_SAVED_COUNTS
  1868. GLOBAL_DEVICE_SAVED_COUNTS[self.device_id] = GLOBAL_DEVICE_SAVED_COUNTS.get(self.device_id, 0) + 1
  1869. self.loggerPdd.info(
  1870. f"========== 设备 [{self.device_id}] 存入数据库成功,当前该设备总计采集数据: {GLOBAL_DEVICE_SAVED_COUNTS[self.device_id]} 条 ==========")
  1871. return True
  1872. except Exception as e:
  1873. print(f'保存数据库异常 (尝试 {attempt + 1}/{max_retries}): {e}')
  1874. if conn:
  1875. conn.rollback()
  1876. conn.close()
  1877. if attempt == max_retries - 1:
  1878. print("达到最大重试次数,保存失败")
  1879. return False
  1880. time.sleep(2)
  1881. def click_target_product_by_search_key(self, fuzzy_match=False, timeout=10):
  1882. # 功能:在列表页重新定位当前搜索词对应的商品,常用于异常恢复后的重新对焦。
  1883. """
  1884. 动态匹配self.search_key对应的商品并点击
  1885. :param fuzzy_match: 是否模糊匹配(应对商品名带额外后缀/前缀的情况) 不模糊匹配
  1886. :param timeout: 等待元素出现的超时时间(秒)
  1887. :return: 点击是否成功(bool)
  1888. """
  1889. try:
  1890. # 1. 定义定位条件(动态使用self.search_key)
  1891. # 异常恢复后需要重新找到"当前任务真正想点的那一个商品",
  1892. # 这里支持精确和模糊两种定位策略。
  1893. if fuzzy_match:
  1894. # 模糊匹配:包含search_key即可(推荐,适配搜索结果商品名略有差异)
  1895. locator = self.d(textContains=self.search_key)
  1896. print(f"🔍 模糊匹配商品:包含「{self.search_key}」的元素")
  1897. else:
  1898. # 精确匹配:商品名与search_key完全一致
  1899. locator = self.d(text=self.search_key)
  1900. print(f"🔍 精确匹配商品:「{self.search_key}」")
  1901. # 2. 等待元素出现(核心:避免元素未加载就点击)
  1902. if locator.wait(timeout=timeout):
  1903. print(f"✅ 找到匹配的商品,准备点击")
  1904. # 执行点击(优先点击可点击的元素)
  1905. locator.click()
  1906. print(f"✅ 成功点击「{self.search_key}」对应的商品")
  1907. # 点击后等待页面加载
  1908. time.sleep(self.get_sleep_time())
  1909. return True
  1910. else:
  1911. print(f"❌ 滑动后仍未找到「{self.search_key}」对应的商品")
  1912. return False
  1913. except Exception as e:
  1914. print(f"❌ 点击「{self.search_key}」对应商品时异常:{e}")
  1915. return False
  1916. def swipe_down(self):
  1917. # 功能:执行带随机性的向下滑动,兼顾页面恢复、回找搜索框和设备适配。
  1918. """
  1919. 下滑(模拟真人操作,抗风控+设备适配+容错)
  1920. 核心:起点在屏幕上方,终点在屏幕下方(和上滑相反)
  1921. :return: None
  1922. """
  1923. try:
  1924. # 1. 获取屏幕尺寸(兼容不同设备,给默认值避免获取失败)
  1925. screen_width = self.d.info.get('displayWidth', 1080) # 默认1080px宽度
  1926. screen_height = self.d.info.get('displayHeight', 2400) # 默认2400px高度
  1927. # 2. 随机滑动时长(0.1~0.3秒,避免固定值被风控,且不设0秒)
  1928. duration_rate = random.uniform(0.1, 0.3)
  1929. # 3. 计算滑动坐标(用屏幕比例,适配所有设备)
  1930. start_x = screen_width // 2 # 水平居中(和上滑一致,符合真人操作习惯)
  1931. start_y = int(screen_height * 0.8) # 起点:屏幕80%高度(下方偏下)
  1932. end_y = int(screen_height * 0.2) # 终点:屏幕20%高度(上方偏上)
  1933. # 强制确保起点y > 终点y(向上滑动,内容向下翻转)
  1934. start_y, end_y = max(start_y, end_y + 10), min(end_y, start_y - 10)
  1935. # 4. 核心向下滑动操作
  1936. self.d.swipe(start_x, start_y, start_x, end_y, duration=duration_rate)
  1937. # 滑动后全局等待(确保页面加载,避免元素定位失败)
  1938. time.sleep(self.get_sleep_time())
  1939. except Exception as e:
  1940. # 异常捕获:避免设备断开/滑动失败导致程序崩溃
  1941. print(f"向下滑动失败:{e}")
  1942. # 兜底方案:用屏幕比例坐标重试
  1943. sw, sh = self.get_screen_size()
  1944. self.d.swipe(sw // 2, int(sh * 0.2), sw // 2, int(sh * 0.8), duration=0.2)
  1945. time.sleep(self.get_sleep_time())
  1946. def swipe_up(self):
  1947. # 功能:执行向上滑动,用于翻页或继续向下浏览详情。
  1948. """
  1949. 上滑
  1950. :return:
  1951. """
  1952. screen_width = self.d.info['displayWidth']
  1953. screen_height = self.d.info['displayHeight']
  1954. duration_rate = random.uniform(0, 0.3)
  1955. self.d.swipe(screen_width // 2, screen_height - 100, screen_width // 2, 100, duration=duration_rate)
  1956. no = random.uniform(0, 1)
  1957. if no > 0.85:
  1958. # 有的时候卡着 再稍微往上滑一点点
  1959. self.d.swipe_ext("up", 0.1)
  1960. time.sleep(self.get_sleep_time())
  1961. def swipe_back(self, no):
  1962. # 功能:按指定次数执行返回,但只有当前不在列表页时才真正后退。
  1963. """
  1964. 返回
  1965. :param no: 回退次数
  1966. :return:
  1967. """
  1968. for idx in range(no):
  1969. if self.distinct_target(): # 已经在列表页
  1970. print(f'已在列表页,停止返回')
  1971. return True
  1972. self.d.press('back')
  1973. time.sleep(self.get_sleep_time())
  1974. def drug_price(self):
  1975. # 功能:直接从详情页读取价格,作为规格弹窗取价失败时的兜底方案。
  1976. """
  1977. 获取药品价格
  1978. :return:
  1979. """
  1980. try:
  1981. xpath = '//*[@text="¥"]/following-sibling::android.widget.TextView[1]'
  1982. price_str = self.d.xpath(xpath).text
  1983. price = float(re.search(r'[\d\.]+', price_str).group())
  1984. print(f'获取到价格:{price}')
  1985. return float(price)
  1986. except Exception as e:
  1987. print(f'提取价格出错-->{e}')
  1988. return None
  1989. def drug_price_ex(self):
  1990. # 功能:优先从规格选择弹窗里同时提取价格和"已选规格"文本。
  1991. price_str = '' # 价格初始化
  1992. ext = '' # 初始化已选择的信息
  1993. price = ''
  1994. # 阶段 1:先尝试打开规格/品规弹窗,因为后续价格和规格文本都依赖这个弹窗内容。
  1995. # 这是点击进入品规的按钮
  1996. button_xpath_1 = '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[last()]'
  1997. button_xpath_2 = '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[last()]'
  1998. button_xpath_3 = '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.LinearLayout/android.widget.LinearLayout/android.widget.LinearLayout[2]/android.widget.LinearLayout/android.view.ViewGroup'
  1999. # 调试
  2000. # test_button = self.d.xpath(button_xpath_1).exists
  2001. # print(test_button)
  2002. # test_button_2 = self.d.xpath(button_xpath_2).exists
  2003. # print(test_button_2)
  2004. # time.sleep(1000)
  2005. # if self.d.xpath('//*[@text="发起拼单"]').exists:
  2006. # self.d.xpath('//*[@text="发起拼单"]').click()
  2007. # elif self.d.xpath('//*[@text="去复诊开药"]').exists:
  2008. # self.d.xpath('//*[@text="去复诊开药"]').click()
  2009. if self.d.xpath(button_xpath_1).exists:
  2010. self.d.xpath(button_xpath_1).click()
  2011. elif self.d.xpath(button_xpath_2).exists:
  2012. self.d.xpath(button_xpath_2).click()
  2013. elif self.d.xpath(button_xpath_3).exists:
  2014. self.d.xpath(button_xpath_3).click()
  2015. else:
  2016. print("button1 and button_2 and button_3 all not exist")
  2017. return price, ext
  2018. # 阶段 2:根据不同弹窗布局选择对应的 XPath 解析策略。
  2019. select_xpath_1 = '//*[@resource-id="android:id/content"]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.TextView[last()]'
  2020. select_xpath_2 = '//*[@resource-id="android:id/content"]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.RelativeLayout[1]/android.widget.TextView[last()]'
  2021. select_xpath_3 = '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[2]/android.widget.LinearLayout[1]/android.view.ViewGroup[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.view.ViewGroup[1]/android.widget.TextView[last()]'
  2022. select_xpath_3_2 = '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[2]/android.widget.LinearLayout[1]/android.view.ViewGroup[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.view.ViewGroup[1]/android.widget.TextView[last()-1]'
  2023. price_xpath_1 = '//*[@resource-id="android:id/content"]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.TextView[1]'
  2024. price_xpath_2 = '//*[@resource-id="android:id/content"]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.RelativeLayout[1]/android.widget.TextView[1]'
  2025. price_xpath_3 = '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[2]/android.widget.LinearLayout[1]/android.view.ViewGroup[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.view.ViewGroup[1]//android.widget.TextView[1]'
  2026. # 新版规格选项路径(androidx RecyclerView,兼容新版拼多多弹窗)
  2027. scroll_xpath_2 = '//*[@resource-id="android:id/content"]//android.widget.ScrollView[1]/android.widget.LinearLayout/androidx.recyclerview.widget.RecyclerView/android.widget.LinearLayout/android.view.ViewGroup[1]'
  2028. recycler_view_xpath_2 = '//*[@resource-id="android:id/content"]//androidx.recyclerview.widget.RecyclerView[1]/android.widget.LinearLayout/android.view.ViewGroup[1]'
  2029. if self.d.xpath(select_xpath_1).exists:
  2030. text1 = self.d.xpath(select_xpath_1).text
  2031. print(f"select_xpath_1--text1={text1}")
  2032. # 这里先判断是否已经有默认规格,是为了减少额外点击;
  2033. # 如果已经存在"已选"文本,后续可以直接读取价格和规格。
  2034. if '已选' in text1:
  2035. if self.d.xpath(price_xpath_1).exists:
  2036. price_str = self.d.xpath(price_xpath_1).text
  2037. print(f"select_xpath_1--price_str-1={price_str}")
  2038. else:
  2039. print("select_xpath_1--price_xpath_1-1 not exist")
  2040. ext = text1
  2041. elif '请选择' in text1:
  2042. # 调用 click() 的目的是补齐一次规格选择动作,
  2043. # 调用后价格文本和"已选规格"文案才会稳定刷新出来。
  2044. # 需要再下面点击选择
  2045. scroll_xpath_1 = '//*[@resource-id="android:id/content"]//android.widget.ScrollView[1]/android.widget.LinearLayout[1]/android.support.v7.widget.RecyclerView[1]/android.widget.LinearLayout[last()]/android.view.ViewGroup[1]/android.view.ViewGroup[last()]'
  2046. scroll_xpath_2 = ''
  2047. if self.d.xpath(scroll_xpath_1).exists:
  2048. self.d.xpath(scroll_xpath_1).click()
  2049. time.sleep(2) # 延时2秒钟,选择了之后价格会刷新
  2050. if self.d.xpath(select_xpath_1).exists:
  2051. text2 = self.d.xpath(select_xpath_1).text
  2052. if '已选' in text2:
  2053. print(f"select_xpath_1--已选择2:text2={text2}")
  2054. if self.d.xpath(price_xpath_1).exists:
  2055. price_str = self.d.xpath(price_xpath_1).text
  2056. print(f"select_xpath_1--price_str-2={price_str}")
  2057. else:
  2058. print("select_xpath_1--price_xpath_1-2 not exist")
  2059. ext = text2
  2060. else:
  2061. print("select_xpath_1--scroll_xpath_1 not exist")
  2062. elif self.d.xpath(select_xpath_2).exists:
  2063. text1 = self.d.xpath(select_xpath_2).text
  2064. print(f"xpath2--text1={text1}")
  2065. if '已选' in text1:
  2066. ext = text1
  2067. if self.d.xpath(price_xpath_2).exists:
  2068. price_str = self.d.xpath(price_xpath_2).text
  2069. print(f"select_xpath_2--price_str-2={price_str}")
  2070. else:
  2071. print("select_xpath_2--price_xpath_2-1 not exist")
  2072. elif '请选择' in text1:
  2073. # 当前布局下如果不先选择一个规格,后续既拿不到准确价格,也无法计算盒数。
  2074. print('come in here')
  2075. # 需要再下面点击选择
  2076. scroll_xpath_1 = '//*[@resource-id="android:id/content"]//android.widget.ScrollView[1]/android.widget.LinearLayout[1]/android.support.v7.widget.RecyclerView[1]/android.widget.LinearLayout[last()]/android.view.ViewGroup[1]/android.view.ViewGroup[1]'
  2077. hit_scroll = False
  2078. if self.d.xpath(scroll_xpath_1).exists:
  2079. print("scroll_xpath_1 exists")
  2080. self.d.xpath(scroll_xpath_1).click()
  2081. hit_scroll = True
  2082. elif self.d.xpath(scroll_xpath_2).exists:
  2083. print("scroll_xpath_2 exists")
  2084. self.d.xpath(scroll_xpath_2).click()
  2085. hit_scroll = True
  2086. if hit_scroll:
  2087. time.sleep(2) # 延时2秒钟,选择了之后价格可能会刷新
  2088. if self.d.xpath(select_xpath_2).exists:
  2089. text2 = self.d.xpath(select_xpath_2).text
  2090. if '已选' in text2:
  2091. ext = text2
  2092. print(f"select_xpath_2--已选择2:text2={text2}")
  2093. if self.d.xpath(price_xpath_2).exists:
  2094. price_str = self.d.xpath(price_xpath_2).text
  2095. print(f"select_xpath_2--price_str-2={price_str}")
  2096. else:
  2097. print("select_xpath_2--price_xpath_2-2 not exist")
  2098. else:
  2099. print("scroll_xpath_1 and scroll_xpath_2 not exists")
  2100. else:
  2101. print("not exist 请选择 or 已选")
  2102. elif self.d.xpath(select_xpath_3).exists:
  2103. text1 = self.d.xpath(select_xpath_3).text
  2104. print(f"xpath3--text1-1={text1}")
  2105. if ('请选择' not in text1) and ('已选' not in text1):
  2106. text1 = self.d.xpath(select_xpath_3_2).text
  2107. print(f"xpath3--text1-2={text1}")
  2108. if '已选' in text1:
  2109. ext = text1
  2110. if self.d.xpath(price_xpath_3).exists:
  2111. price_str = self.d.xpath(price_xpath_3).text
  2112. print(f"select_xpath_3--price_str-3-3-1={price_str}")
  2113. else:
  2114. print("select_xpath_3--price_xpath_3-3-1 not exist")
  2115. elif '请选择' in text1:
  2116. # 这一支兼容另一类规格弹窗结构,核心目标仍然是先拿到"已选"文本。
  2117. print('come in here')
  2118. # 需要再下面点击选择
  2119. scroll_xpath_1 = '//*[@resource-id="android:id/content"]//android.widget.ScrollView[1]/android.widget.LinearLayout[1]/android.support.v7.widget.RecyclerView[1]/android.widget.LinearLayout[last()]/android.view.ViewGroup[1]/android.view.ViewGroup[1]'
  2120. recycler_view_xpath = '//*[@resource-id="android:id/content"]//android.support.v7.widget.RecyclerView[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[last()]/android.view.ViewGroup[1]/android.view.ViewGroup[1]'
  2121. hit_scroll = False
  2122. if self.d.xpath(scroll_xpath_1).exists:
  2123. print("scroll_xpath_1 exists")
  2124. self.d.xpath(scroll_xpath_1).click()
  2125. hit_scroll = True
  2126. elif self.d.xpath(recycler_view_xpath).exists:
  2127. self.d.xpath(recycler_view_xpath).click()
  2128. hit_scroll = True
  2129. elif self.d.xpath(scroll_xpath_2).exists:
  2130. print("scroll_xpath_2 exists")
  2131. self.d.xpath(scroll_xpath_2).click()
  2132. hit_scroll = True
  2133. elif self.d.xpath(recycler_view_xpath_2).exists:
  2134. print("recycler_view_xpath_2 exists")
  2135. self.d.xpath(recycler_view_xpath_2).click()
  2136. hit_scroll = True
  2137. if hit_scroll:
  2138. time.sleep(2) # 延时2秒钟,选择了之后价格可能会刷新
  2139. if self.d.xpath(select_xpath_3).exists:
  2140. text2 = self.d.xpath(select_xpath_3).text
  2141. if '已选' in text2:
  2142. ext = text2
  2143. print(f"select_xpath_3--已选择2:text2={text2}")
  2144. if self.d.xpath(price_xpath_3).exists:
  2145. price_str = self.d.xpath(price_xpath_3).text
  2146. print(f"select_xpath_3--price_str-3-2={price_str}")
  2147. else:
  2148. print("select_xpath_3--price_xpath_3-3-2 not exist")
  2149. else:
  2150. print("scroll_xpath_1, recycler_view_xpath, scroll_xpath_2, recycler_view_xpath_2 all not exists")
  2151. else:
  2152. print(f"xpath3--text1-不包含请选择和已选择")
  2153. else:
  2154. print("select_xpath_1 and select_xpath_2 and select_xpath_3 all not exist")
  2155. # 阶段 3:从界面文案中抽取纯价格值,供后续去重和单盒价格计算。
  2156. if price_str:
  2157. # price = float(re.search('[\d\.]+', price_str).group())
  2158. match = re.search(r'¥([\d\.]+)', price_str)
  2159. if match:
  2160. price = float(match.group(1))
  2161. else:
  2162. price = ''
  2163. # price = float(re.search(r'¥([\d\.]+)', price_str).group(1))
  2164. print(f'获取到价格:{price}')
  2165. print(f"ext={ext}")
  2166. # 调用 swipe_back() 的目的是把页面从规格弹窗恢复回商品详情页,
  2167. # 后续提取店铺名、链接和说明书都依赖当前仍停留在详情页。
  2168. self.swipe_back(1) #
  2169. return price, ext
  2170. def restart_uiautomator_services(self, device_id):
  2171. # 功能:重启设备上的 atx-agent/uiautomator 服务,恢复自动化控制能力。
  2172. """
  2173. 重启atx的uiautomator 服务
  2174. :param device_id:
  2175. :return:
  2176. """
  2177. stop_uiautomator_services = f'adb -s {device_id} shell /data/local/tmp/atx-agent server -d --stop'
  2178. start_uiautomator_services = f'adb -s {device_id} shell /data/local/tmp/atx-agent server -d'
  2179. subprocess.run(stop_uiautomator_services, capture_output=True, text=True, shell=True)
  2180. time.sleep(self.get_sleep_time())
  2181. subprocess.run(start_uiautomator_services, capture_output=True, text=True, shell=True)
  2182. time.sleep(self.get_sleep_time())
  2183. def connect_devices(self, device_id):
  2184. # 功能:建立 USB 设备连接,并把自动化服务重置到可用状态。
  2185. """
  2186. 连接设备
  2187. :return:
  2188. """
  2189. try:
  2190. self.d = u2.connect_usb(device_id)
  2191. # (已移除全局 ANR 监控,后台轮询会导致滑动时卡顿/抽搐,触发拼多多防跑封控)
  2192. # 设置隐形等待时间
  2193. # self.d.implicitly_wait(5)
  2194. # 连上设备后主动重启 atx-agent,减少长时间运行后的控件失效问题。
  2195. self.restart_uiautomator_services(device_id)
  2196. print(f'[{self.program_start_time}]连接到设备:{device_id}')
  2197. except Exception as e:
  2198. print(f'{device_id} 连接错误: {e}')
  2199. raise Exception(e)
  2200. # 验证码 WebView XPath — 所有验证码相关页面都有这个元素
  2201. CAPTCHA_WEBVIEW = '//android.webkit.WebView[@text="安全验证"]'
  2202. def check_and_solve_captcha(self):
  2203. """检测验证码 WebView 并调用 test_captcha.solve 解决,成功返回 True"""
  2204. if not self.d.xpath(self.CAPTCHA_WEBVIEW).exists:
  2205. return False
  2206. logging.info("检测到验证码页面(WebView 安全验证),开始解决...")
  2207. result = captcha_solve(self.d)
  2208. if result:
  2209. logging.info("验证码已解决,休息30分钟...")
  2210. time.sleep(1800)
  2211. return result
  2212. def get_baidu_ocr_res(self, img):
  2213. try:
  2214. # img地址
  2215. print(f'开始识别图片:{img}')
  2216. request_url = "https://aip.baidubce.com/rest/2.0/ocr/v1/business_license"
  2217. f = open(img, 'rb')
  2218. img = base64.b64encode(f.read())
  2219. params = {"image": img}
  2220. request_url = request_url + "?access_token=" + get_access_token()
  2221. headers = {'content-type': 'application/x-www-form-urlencoded'}
  2222. response = requests.post(request_url, data=params, headers=headers)
  2223. if response:
  2224. res = response.json()
  2225. new_dic = dict()
  2226. for ite in res['words_result'].keys():
  2227. new_dic[ite] = res['words_result'][ite]['words']
  2228. print('资质数据信息', new_dic)
  2229. return new_dic
  2230. else:
  2231. return None
  2232. except:
  2233. return None
  2234. def get_baidu_ocr_jgh_res(self, img):
  2235. try:
  2236. # img地址
  2237. print(f'开始识别图片:{img}')
  2238. request_url = "https://aip.baidubce.com/rest/2.0/ocr/v1/smart_struct"
  2239. f = open(img, 'rb')
  2240. img = base64.b64encode(f.read())
  2241. params = {"image": img}
  2242. request_url = request_url + "?access_token=" + get_access_token()
  2243. headers = {'content-type': 'application/x-www-form-urlencoded'}
  2244. response = requests.post(request_url, data=params, headers=headers)
  2245. if response:
  2246. res = response.json()
  2247. print(f"获取店铺资质信息ocr返回:{res}")
  2248. new_dic = dict()
  2249. data = res['words_result']['struct_info']['group']
  2250. for ite in data:
  2251. new_dic[ite['key'][0]['word']] = ite['value'][0]['word']
  2252. print(f'ocr资质数据信息:{new_dic}')
  2253. return new_dic
  2254. else:
  2255. return None
  2256. except:
  2257. return None
  2258. def get_ocr_res(self, img):
  2259. # 功能:对截图做去水印后调用百度 OCR,返回识别出的文字结果列表。
  2260. try:
  2261. image = self.remove_watermark(img)
  2262. res_image = self.client.basicGeneral(image)
  2263. data = res_image.get('words_result', '')
  2264. print(f'百度api返回结果:{data}')
  2265. return data
  2266. except:
  2267. return None
  2268. def get_title(self):
  2269. # 功能:从商品详情页提取当前标题,作为第一层匹配和落库名称来源。
  2270. try:
  2271. print('开始提取标题')
  2272. time.sleep(self.get_sleep_time())
  2273. title_xpath = '//*[@resource-id="com.xunmeng.pinduoduo:id/tv_title"]'
  2274. if self.d.xpath(title_xpath).exists:
  2275. title = self.d.xpath(title_xpath).info['contentDescription'].strip()
  2276. else:
  2277. # 标题没找到 → 检测是否是验证码页面
  2278. self.check_and_solve_captcha()
  2279. return None
  2280. # title = self.d.xpath('//*[@resource-id="com.xunmeng.pinduoduo:id/tv_title"]').info['contentDescription'].strip()
  2281. print(f'提取到标题:{title}')
  2282. return title
  2283. except Exception as e:
  2284. print(f'获取标题出错:{e}')
  2285. return None
  2286. # 从里面匹配出药品名和规格
  2287. # drugs_name
  2288. # specifications
  2289. # match = re.search(r'([^\d]+)([\d\D]+)', title)
  2290. # match = re.search(r'(\[[^\]]+\])(.+?)(\d+.*)', title)
  2291. # if match:
  2292. # drugs_name = match.group(1).strip() + match.group(2).strip()
  2293. # specifications = match.group(3).strip()
  2294. # print("药品名:", drugs_name)
  2295. # print("规格:", specifications)
  2296. # print('完整药名:', drugs_name + specifications)
  2297. # return drugs_name, specifications
  2298. # else:
  2299. # print("没有匹配到预期格式")
  2300. def enter_shop(self):
  2301. # 功能:进入店铺页,供后续读取店铺或资质信息时使用。
  2302. """
  2303. 进店,方便提取资质环境
  2304. :return:
  2305. """
  2306. # self.d.xpath('//*[@text="进店"]').click()
  2307. self.d.xpath('//*[@text="店铺"]').click()
  2308. time.sleep(self.get_sleep_time())
  2309. # 店铺去重
  2310. def shop_is_exists(self, data):
  2311. # 功能:按店铺去重校验,避免同类数据重复入库。
  2312. # 1. 验证必要字段
  2313. # 先校验去重所需字段是否齐全,避免把不完整的数据带到 SQL 条件里。
  2314. required_keys = ['shop']
  2315. if not all(key in data for key in required_keys):
  2316. missing = [key for key in required_keys if key not in data]
  2317. print(f"缺少必要字段: {', '.join(missing)}")
  2318. return None
  2319. shop_value = data.get('shop')
  2320. if not shop_value or not str(shop_value).strip():
  2321. print("shop 字段为空,无法执行去重查询")
  2322. return False
  2323. conn = None
  2324. try:
  2325. conn = get_mysql()
  2326. with conn.cursor() as cur:
  2327. query_sql = """
  2328. SELECT * FROM {}
  2329. WHERE shop = %s
  2330. LIMIT 1
  2331. """.format(self.shop_table_name)
  2332. cur.execute(query_sql, (
  2333. data['shop']
  2334. ))
  2335. result = cur.fetchone()
  2336. return bool(result) # 如果存在返回True,否则False
  2337. except Exception as e:
  2338. print(f"MySQL 错误: {str(e)}")
  2339. finally:
  2340. if conn:
  2341. conn.close()
  2342. def get_province_city(self, data):
  2343. """
  2344. 从 retrieve_scrape_shop_info 表中查询已存在的 province 和 city,
  2345. 并赋值给 data['province_name'] 和 data['city_name']
  2346. """
  2347. print("获取店铺营业公司对应的省份和城市")
  2348. shop_name = data.get('shop')
  2349. if not shop_name:
  2350. print("shop 字段为空,无法执行查询")
  2351. return
  2352. conn = None
  2353. try:
  2354. conn = get_mysql()
  2355. with conn.cursor() as cur:
  2356. # 查询 shop_info_middle 表,获取 province 和 city
  2357. sql = "SELECT province, city, business_license_company FROM retrieve_scrape_shop_info WHERE shop = %s AND platform = 3 LIMIT 1"
  2358. cur.execute(sql, (shop_name,))
  2359. result = cur.fetchone()
  2360. if result:
  2361. province, city, company = result
  2362. data['province_name'] = province if province else ''
  2363. data['city_name'] = city if city else ''
  2364. data['company_name'] = company if company else ''
  2365. print(f"店铺 {shop_name} 对应的省份和城市为: {province}, {city}, 公司: {company}")
  2366. else:
  2367. print(f"未在 shop_info_middle 表中找到店铺:{shop_name}")
  2368. # 可根据业务需求设置默认值或保持原样
  2369. data['province_name'] = ''
  2370. data['city_name'] = ''
  2371. data['company_name'] = ''
  2372. except Exception as e:
  2373. print(f"查询省市信息失败: {str(e)}")
  2374. # 异常时也可设置默认空值,避免后续代码因缺少键而报错
  2375. data['province_name'] = ''
  2376. data['city_name'] = ''
  2377. finally:
  2378. if conn:
  2379. conn.close()
  2380. def data_is_exists(self, data):
  2381. # 功能:按价格、店铺、日期、平台,账户,轮次做去重校验,避免同类数据重复入库。
  2382. # 1. 验证必要字段
  2383. # 先校验去重所需字段是否齐全,避免把不完整的数据带到 SQL 条件里。
  2384. required_keys = ['min_price', 'shop', 'scrape_date', 'platform']
  2385. if not all(key in data for key in required_keys):
  2386. missing = [key for key in required_keys if key not in data]
  2387. print(f"缺少必要字段: {', '.join(missing)}")
  2388. return None
  2389. conn = None
  2390. try:
  2391. conn = get_mysql()
  2392. with conn.cursor() as cur:
  2393. query_sql = """
  2394. SELECT * FROM {}
  2395. WHERE min_price = %s
  2396. AND store_name = %s
  2397. AND scrape_date = %s
  2398. AND platform_id = %s AND collect_equipment_account_id = %s
  2399. AND collect_round = %s
  2400. """.format(self.table_name)
  2401. cur.execute(query_sql, (
  2402. data['min_price'],
  2403. data['shop'],
  2404. data['scrape_date'],
  2405. data['platform'], data.get('collect_equipment_account_id', 0),
  2406. data.get('collect_round', 0),
  2407. ))
  2408. result = cur.fetchone()
  2409. return bool(result) # 如果存在返回True,否则False
  2410. except Exception as e:
  2411. print(f"MySQL 错误: {str(e)}")
  2412. finally:
  2413. if conn:
  2414. conn.close()
  2415. def get_instructions_data(self):
  2416. # 功能:在详情页中提取说明书/商品参数区域的关键字段,整理成统一字典。
  2417. """
  2418. 确定有详情页之后之后,提取所有的详情页数据
  2419. :return:
  2420. """
  2421. # 先把页面滚到说明书/参数区域附近,再开始解析键值对。
  2422. for i in range(8):
  2423. if self.d.xpath('//*[@text="品牌"]').exists or self.d.xpath('//*[@text="药品通用名"]').exists:
  2424. self.d.swipe_ext("up", scale=0.1)
  2425. print('开始采集详情数据')
  2426. break
  2427. self.d.swipe_ext("up", scale=0.5)
  2428. time.sleep(self.get_sleep_time())
  2429. # 阶段 2:进入"查看全部"区域,把折叠的参数信息完整展开。
  2430. # 点击查看全部
  2431. if self.d.xpath('//*[@text="品牌"]').exists:
  2432. self.d.xpath('//*[@text="品牌"]').click()
  2433. else:
  2434. self.d.xpath('//*[@text="药品通用名"]').click()
  2435. time.sleep(self.get_sleep_time())
  2436. attr = dict()
  2437. # 阶段 3:批量解析键值对文本,构造说明书字段字典。
  2438. # # 获取详情页信息
  2439. xpath = '//*[starts-with(@text,"商品参数")]/parent::*/parent::*/following-sibling::*/*/*/android.view.ViewGroup//android.widget.TextView'
  2440. ddd = self.d.xpath(xpath).all()
  2441. for i in range(0, len(ddd), 2):
  2442. group = ddd[i:i + 2]
  2443. attr[group[0].text] = group[1].text
  2444. # 截图获取未获取到的数据
  2445. # if not all(i in ['有效期', '生产企业', '批准文号', '药品规格', '产品规格'] for i in attr.keys()):
  2446. if not all(i in ['有效期', '生产企业', '批准文号', '药品规格'] for i in attr.keys()):
  2447. # 首轮解析拿不到关键字段时再补一次较短滑动,兼容参数区未完整展示的情况。
  2448. self.d.swipe_ext("up", 0.4)
  2449. time.sleep(self.get_sleep_time())
  2450. xpath = '//*[starts-with(@text,"商品参数")]/parent::*/parent::*/following-sibling::*/*/*/android.view.ViewGroup//android.widget.TextView'
  2451. ddd = self.d.xpath(xpath).all()
  2452. for i in range(0, len(ddd), 2):
  2453. group = ddd[i:i + 2]
  2454. attr[group[0].text] = group[1].text
  2455. print(f'当前说明书规格参数:{attr}')
  2456. res_data = {
  2457. # "有效期": attr.get('有效期',''),
  2458. # "生产单位": attr['生产企业'],
  2459. # "批准文号": attr['批准文号'],
  2460. # "产品规格": attr.get('药品规格') if attr.get('药品规格', '') else attr.get('药品规格')
  2461. "有效期": attr.get('有效期', ''),
  2462. "生产单位": attr.get('生产企业', ''),
  2463. "批准文号": attr.get('批准文号', ''),
  2464. "产品规格": attr.get('药品规格', ''),
  2465. "发货地": attr.get('发货地', '')
  2466. }
  2467. print(f'当前规格参数字典数据:{res_data}')
  2468. return res_data
  2469. def has_instructions(self):
  2470. # 功能:判断当前详情页能否找到说明书/商品详情区域。
  2471. """
  2472. 是否有详情页
  2473. :return:如果有详情页返回True,否则返回False
  2474. """
  2475. # 没有说明书的无法采集具体数据
  2476. max_attempts = 12 # 最大尝试次数
  2477. attempt = 0 # 当前尝试次数
  2478. while attempt < max_attempts:
  2479. time.sleep(0.5)
  2480. xpath = '//*[@text="商品详情"]'
  2481. is_has_instructions = self.d.xpath(xpath).exists
  2482. if is_has_instructions:
  2483. return True # 如果找到"商品详情",则返回True
  2484. self.d.swipe_ext("up", 0.3)
  2485. attempt += 1
  2486. return False # 如果尝试次数达到最大次数,则返回False
  2487. def distinct_target(self):
  2488. # 功能:判断当前页面是否已经回到商品列表页。
  2489. try:
  2490. if self.is_pdd_home_page():
  2491. return False
  2492. # 极速查询:改用原生查询,避免慢速 XPath 阻塞执行
  2493. filter_bar = self.d(text="筛选").exists
  2494. search_camera = self.d(description="拍照搜索").exists
  2495. sort_tab = self.d(text="综合").exists or self.d(text="销量").exists
  2496. # 使用 className 查询避免 dump hierarchy
  2497. list_container = self.d(className="androidx.recyclerview.widget.RecyclerView").exists or \
  2498. self.d(className="android.support.v7.widget.RecyclerView").exists
  2499. if not list_container:
  2500. # 兼容兜底
  2501. list_container = self.d.xpath('//android.support.v7.widget.RecyclerView').exists or \
  2502. self.d.xpath('//androidx.recyclerview.widget.RecyclerView').exists
  2503. result = False
  2504. # 1. 列表顶部特征:筛选栏、拍照搜索、综合排序或销量依然可见
  2505. if filter_bar or search_camera or sort_tab:
  2506. result = True
  2507. # 2. 深度滑动特征:头部已隐藏,只要存在商品列表,且不在输入页、不在详情页,即认为是搜索结果页
  2508. elif list_container:
  2509. is_search_input = self.d(className='android.widget.EditText').exists
  2510. is_detail_page = self.d(text="进店").exists or self.d(text="客服").exists
  2511. if not is_search_input and not is_detail_page:
  2512. result = True
  2513. print(
  2514. f"distinct_target -> filter_bar={filter_bar}, search_camera={search_camera}, list_container={list_container}, result={result}")
  2515. return result
  2516. except Exception as e:
  2517. if hasattr(self, 'loggerPdd'):
  2518. self.loggerPdd.error(f"distinct_target 判断出错: {e}")
  2519. return False
  2520. def enter_target_page(self):
  2521. # 功能:进入搜索页、输入关键字并恢复排序/页位,为主循环建立起始页面。
  2522. # 阶段 1:进入搜索框并提交当前任务的搜索词。
  2523. self.prepare_entry_before_enter_target(force_restart=False)
  2524. # 注意:back_to_pdd_home_page + focus_search_box 已由 prepare_entry_before_enter_target 完成,这里不再重复。
  2525. search_entry_candidates = [
  2526. '//*[contains(@text, "搜索") and not(contains(@text, "拍照"))]',
  2527. '//*[contains(@content-desc, "搜索") and not(contains(@content-desc, "拍照"))]',
  2528. '//*[@resource-id="android:id/content"]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]'
  2529. '/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]'
  2530. '/android.widget.LinearLayout[1]',
  2531. ]
  2532. # 多轮重试:避免"按 back 误退到桌面"导致搜索框始终找不到。
  2533. for attempt in range(4):
  2534. if self.d(className='android.widget.EditText').wait(timeout=2):
  2535. break
  2536. if not self.is_pdd_foreground():
  2537. logging.info("enter_target_page: pdd不在前台,尝试重新拉起APP")
  2538. self.start_app()
  2539. time.sleep(self.get_sleep_time())
  2540. clicked_search_entry = False
  2541. for entry_xpath in search_entry_candidates:
  2542. if self.d.xpath(entry_xpath).exists:
  2543. self.d.xpath(entry_xpath).click()
  2544. clicked_search_entry = True
  2545. time.sleep(self.get_sleep_time())
  2546. if self.d(className='android.widget.EditText').wait(timeout=2):
  2547. break
  2548. if self.d(className='android.widget.EditText').wait(timeout=1):
  2549. break
  2550. # 兜底:点击顶部中间区域,兼容节点层级变化但视觉位置不变的机型。
  2551. if not clicked_search_entry:
  2552. try:
  2553. screen_width = self.d.info.get('displayWidth', 1080)
  2554. screen_height = self.d.info.get('displayHeight', 2400)
  2555. self.d.click(screen_width // 2, int(screen_height * 0.08))
  2556. time.sleep(self.get_sleep_time())
  2557. except Exception as click_error:
  2558. logging.info(f"enter_target_page: 顶部兜底点击失败: {click_error}")
  2559. if self.d(className='android.widget.EditText').wait(timeout=1):
  2560. break
  2561. logging.info(f"enter_target_page: 第 {attempt + 1}/4 次仍未找到搜索输入框,继续重试")
  2562. if not self.d(className='android.widget.EditText').wait(timeout=2):
  2563. current_package = (self.d.app_current() or {}).get("package", "")
  2564. raise RuntimeError(f"enter_target_page: search input not found, current_package={current_package}")
  2565. self.d(className='android.widget.EditText').click()
  2566. time.sleep(2.0) # 强制等待,确保搜索页面的转场动画完全结束且输入框真正获取焦点
  2567. try:
  2568. self.d.send_keys(self.search_key, clear=True)
  2569. except Exception as e:
  2570. logging.warning(f"enter_target_page: send_keys 抛出异常: {e},尝试备用输入方案...")
  2571. # 备用方案:直接使用 set_text 设置文本,绕过键盘输入
  2572. try:
  2573. self.d(className='android.widget.EditText').set_text(self.search_key)
  2574. except Exception as e2:
  2575. logging.error(f"备用输入方案也失败: {e2}")
  2576. time.sleep(self.get_sleep_time())
  2577. if self.d.xpath('//*[@text="搜索"]').exists:
  2578. self.d.xpath('//*[@text="搜索"]').click()
  2579. else:
  2580. self.d.send_action("search")
  2581. time.sleep(self.get_sleep_time())
  2582. # 验证是否成功进入了搜索结果页
  2583. entered = False
  2584. for _ in range(10):
  2585. if self.distinct_target():
  2586. entered = True
  2587. break
  2588. time.sleep(1)
  2589. if not entered:
  2590. current_package = (self.d.app_current() or {}).get("package", "")
  2591. raise RuntimeError(f"enter_target_page: 搜索后未能成功进入搜索结果页, current_package={current_package}")
  2592. # 阶段 2:如果任务要求排序,则在首次进入结果页后先切到目标排序方式。
  2593. # 排序只在进入列表后的第一次执行,避免恢复进度时重复切换排序方向。
  2594. # if self.sort and self.sort_key == 0:
  2595. # self.li_or_lo(self.sort)
  2596. # progress = self.wr_re("读", self.device_id)
  2597. progress = None
  2598. # 阶段 3:如有历史页码,则把列表大致恢复到目标位置。
  2599. # if self.page > 0:
  2600. # self.scroll_to_target_page(self.page)
  2601. def get_clipboard(self):
  2602. # 功能:读取设备剪贴板内容,并去掉空值和首尾空白。
  2603. self.loggerPdd.info(f"Clipboard content:{self.d.clipboard}") # 打印调试信息
  2604. clipboard_content = self.d.clipboard
  2605. if clipboard_content is None:
  2606. return ''
  2607. return clipboard_content.strip()
  2608. def get_product_link(self):
  2609. # 功能:通过商品详情页的分享入口复制商品链接。
  2610. product_link = ''
  2611. print('开始获取商品链接')
  2612. content_frame = self.d.xpath('//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]').exists
  2613. print(content_frame)
  2614. relative_layout = self.d.xpath(
  2615. '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]').exists
  2616. print(relative_layout)
  2617. relative_layout2 = self.d.xpath(
  2618. '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.RelativeLayout[1]').exists
  2619. print(relative_layout2)
  2620. Frame_Layout = self.d.xpath(
  2621. '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[2]').exists
  2622. print(Frame_Layout)
  2623. ImageView = self.d.xpath(
  2624. '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[2]/android.view.View[1]').exists
  2625. print(ImageView)
  2626. ImageView2 = self.d.xpath(
  2627. '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[3]/android.view.View[1]').exists
  2628. print(ImageView2)
  2629. # 多种可能的"分享"按钮
  2630. # 分享入口在不同商品页布局里位置不稳定,因此保留多套候选 XPath。
  2631. dots_xpaths = [
  2632. # '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[2]/android.view.View[1]',
  2633. '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[last()]/android.view.View[1]',
  2634. # '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[2]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[2]/android.view.View[1]',
  2635. # '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[2]/android.widget.RelativeLayout[2]/android.widget.FrameLayout[3]/android.widget.ImageView[1]',
  2636. ]
  2637. # 阶段 1:遍历候选分享入口,找到当前布局下可点击的"更多/分享"按钮。
  2638. max_retry = 5 # 最多尝试次数
  2639. # 分享面板偶尔会因为动画或按钮未露出而失败,因此允许多次重试。
  2640. for idx in range(1, max_retry + 1):
  2641. if product_link: # 已经拿到则退出
  2642. break
  2643. for xp in dots_xpaths:
  2644. if self.d.xpath(xp).exists:
  2645. # print(f'{idx}-进入分享点点点')
  2646. self.loggerPdd.info(f'{idx}-进入分享点点点')
  2647. # 调用 click() 的目的是打开分享面板;
  2648. # 后续 slide_link() 和"复制链接"点击都依赖分享面板已经展开。
  2649. self.d.xpath(xp).click()
  2650. time.sleep(1)
  2651. self.loggerPdd.info('开始滑动')
  2652. # 这里先调用 slide_link(),是为了把"复制链接"按钮滑到当前可见区域。
  2653. self.slide_link()
  2654. time.sleep(0.2)
  2655. # 获取复制前的剪贴板,用于判断是否真的复制成功
  2656. old_clipboard = self.get_clipboard()
  2657. # 调用 click_exists() 的目的是直接触发系统复制动作,
  2658. # 调用后 get_clipboard() 才有机会读到最新商品链接。
  2659. self.d.xpath('//*[@text="复制链接"]').click_exists()
  2660. time.sleep(1)
  2661. product_link = self.get_clipboard()
  2662. if product_link == old_clipboard and product_link != "":
  2663. self.loggerPdd.warning("剪贴板内容未变,复制可能失败,准备重试")
  2664. product_link = ""
  2665. continue
  2666. time.sleep(0.5)
  2667. self.loggerPdd.info(f'{idx}-商品链接:{product_link}')
  2668. break # 找到并执行后跳出内层循环
  2669. if not product_link and idx < max_retry:
  2670. time.sleep(0.5) # 最后一次不需要再等待
  2671. # time.sleep(100000)
  2672. return product_link
  2673. def integrate_data_v2(self):
  2674. # 功能:在单个商品详情页内完成价格、链接、店铺、说明书、去重和落库的完整聚合流程。
  2675. """
  2676. 基于入口配置统一校验标题、品牌和品规,替代内部大量硬编码分支。
  2677. """
  2678. # 阶段 1:先拿价格和标题,并在最早阶段过滤无关商品。
  2679. # 价格优先走规格弹窗,因为这里还能顺便拿到已选规格文本。
  2680. min_price, ext = self.drug_price_ex()
  2681. title_info = self.get_title()
  2682. if not title_info:
  2683. print('标题获取为空')
  2684. return
  2685. # ========== 盒数提取(放在标题判断之后) ==========
  2686. from spiders.pdd.box_script import extract_quantity_and_unit
  2687. quantity, unit = extract_quantity_and_unit(ext)
  2688. if quantity:
  2689. print(f"✅ 提取到数量: {quantity}{unit}")
  2690. one_box_price = min_price / quantity if min_price else 0
  2691. else:
  2692. print(f"⚠️ 未提取到数量,ext={ext}")
  2693. one_box_price = 0
  2694. # =============================================
  2695. # 先只按标题/品牌做一次粗过滤,尽早淘汰无关商品。
  2696. if not self.is_link_useful(title_info):
  2697. self.unrelated_data += 1
  2698. return
  2699. # 规格弹窗提价失败时,再回退到详情页直接取价。
  2700. if not min_price:
  2701. min_price = self.drug_price()
  2702. if not min_price:
  2703. print('提取价格出错,回退到列表页')
  2704. self.unrelated_data += 1
  2705. return
  2706. # 阶段 2:补齐商品链接和店铺信息,这两类字段是后续落库和去重的关键上下文。
  2707. product_link = self.get_product_link()
  2708. time.sleep(2)
  2709. oss_url = ''
  2710. try:
  2711. result = self.screenshot_and_upload_oss()
  2712. if result:
  2713. oss_url = result
  2714. print(f"OSS快照上传成功: {oss_url}")
  2715. else:
  2716. print("OSS快照上传失败(返回空)")
  2717. except Exception as e:
  2718. print(f"OSS快照异常: {e}")
  2719. oss_url = ''
  2720. # 有的页面店铺信息不在首屏,这里按配置决定是否直接读取还是先滑动到店铺区域。
  2721. if self.direct_shop_lookup:
  2722. shop = self.get_shop_name()
  2723. else:
  2724. # 先检查首屏是否已有店铺信息(进店按钮 或 店铺名关键词 或 底部店铺tab)
  2725. has_shop_anchor = (
  2726. self.d(textStartsWith="进店").exists
  2727. or self.d.xpath('//*[@content-desc="店铺"]').exists
  2728. or any(
  2729. self.d.xpath(f'//android.widget.TextView[contains(@text, "{kw}")]').exists
  2730. for kw in ['大药房', '旗舰店', '专卖店', '药房']
  2731. )
  2732. )
  2733. if not has_shop_anchor:
  2734. for _ in range(3):
  2735. self.d.swipe_ext("up", scale=0.3)
  2736. time.sleep(self.get_sleep_time())
  2737. if (
  2738. self.d(textStartsWith="进店").exists
  2739. or self.d.xpath('//*[@content-desc="店铺"]').exists
  2740. or any(
  2741. self.d.xpath(f'//android.widget.TextView[contains(@text, "{kw}")]').exists
  2742. for kw in ['大药房', '旗舰店']
  2743. )
  2744. ):
  2745. print('可以开始获取店铺名')
  2746. break
  2747. shop = self.get_shop_name()
  2748. if not shop:
  2749. print('当前店铺名称为空')
  2750. self.unrelated_data += 1
  2751. return
  2752. scrape_date = self.get_current_date()
  2753. dup_data = {
  2754. 'min_price': min_price,
  2755. 'shop': shop,
  2756. 'scrape_date': scrape_date,
  2757. 'platform': '3',
  2758. 'collect_equipment_account_id': self.collect_equipment_account_id or 0,
  2759. 'collect_round': self.collect_round or 0,
  2760. }
  2761. # 同一天同店铺同价格的数据视为重复,避免重复入库。
  2762. if self.data_is_exists(dup_data):
  2763. print('存在相同数据不入库')
  2764. self.back_to_list_page()
  2765. return
  2766. shop_data = {
  2767. 'shop': shop,
  2768. 'store_url': product_link,
  2769. 'scrape_date': scrape_date,
  2770. 'platform': 3,
  2771. 'create_time': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
  2772. 'update_time': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  2773. }
  2774. province_name = ''
  2775. city_name = ''
  2776. company_name = ''
  2777. # 插入店铺数据
  2778. if self.shop_is_exists(shop_data):
  2779. print("店铺数据已存在,进行省市回填")
  2780. self.get_province_city(shop_data)
  2781. province_name = shop_data['province_name']
  2782. city_name = shop_data['city_name']
  2783. company_name = shop_data.get('company_name', '')
  2784. else:
  2785. # 获取产品详情页的店铺资质
  2786. shop_info = self.get_license_info()
  2787. print(f"店铺数据不存在,获取店铺资质信息:{shop_info}")
  2788. print(f"店铺数据不存在,插入{self.shop_table_name}店铺表")
  2789. if shop_info:
  2790. shop_data.update(shop_info)
  2791. print(f"组合店铺数据:{shop_data}")
  2792. self.save_to_shop_database(shop_data)
  2793. # 从合并后的 shop_data 提取字段,供后续 save_data 写入 retrieve_scrape_data
  2794. province_name = shop_data.get('province', '')
  2795. city_name = shop_data.get('city', '')
  2796. company_name = shop_data.get('business_license_company', '')
  2797. # 阶段 3:确认是否存在说明书页,并在有说明书时补提取规格、生产单位和批准文号。
  2798. is_has_instructions = self.has_instructions()
  2799. self.loggerPdd.info(f'是否有说明书:{is_has_instructions}')
  2800. manufacture_date = ''
  2801. shipmentProvinceName = ''
  2802. credit_code = ext
  2803. # 说明书页不是每个商品都有;没有时允许继续落库,只是相关字段留空。
  2804. province_id, city_id = self._resolve_region_ids(province_name, city_name)
  2805. if is_has_instructions:
  2806. try:
  2807. instructions_info = self.get_instructions_data()
  2808. expiry_date = instructions_info['有效期'].strip('。')
  2809. manufacturer = instructions_info['生产单位'].strip('。')
  2810. approval_number = instructions_info['批准文号'].strip('。')
  2811. specifications = instructions_info['产品规格'].strip('。')
  2812. shipmentProvinceName = instructions_info.get('发货地', '').strip('。')
  2813. except Exception as e:
  2814. print(f'获取详情页规格参数出错:{e}')
  2815. return
  2816. else:
  2817. expiry_date = ''
  2818. manufacturer = ''
  2819. approval_number = ''
  2820. specifications = ''
  2821. # 二次校验把说明书里的规格也纳入判断,避免标题模糊匹配带来误采。
  2822. if not self.is_link_useful(title_info, specifications):
  2823. self.unrelated_data += 1
  2824. return
  2825. self.unrelated_data = 0
  2826. # 不再依赖盒数处理脚本,单盒价格默认回退为 0。
  2827. one_box_price = 0
  2828. # 阶段 4:把当前详情页提取结果整理成统一落库结构。
  2829. save_data = {
  2830. 'enterprise_id': self.enterprise_id,
  2831. 'platform_id': self.platform,
  2832. 'platform_item_id': '',
  2833. 'province_id': province_id,
  2834. 'city_id': city_id,
  2835. 'province_name': province_name,
  2836. 'city_name': city_name,
  2837. 'area_info': "",
  2838. 'product_brand': self.brand,
  2839. 'product_name': title_info,
  2840. 'product_specs': specifications,
  2841. 'search_name': self.search_key,
  2842. 'collect_config_info': self.collect_config_info,
  2843. 'one_box_price': one_box_price,
  2844. 'manufacture_date': manufacture_date,
  2845. 'expiry_date': expiry_date,
  2846. 'manufacturer': manufacturer,
  2847. 'approval_number': approval_number,
  2848. 'is_sold_out': 0,
  2849. 'online_posting_count': 1,
  2850. 'continuous_listing_count': 1,
  2851. 'link_url': product_link,
  2852. 'store_name': shop,
  2853. 'store_url': '',
  2854. 'shipment_province_id': 0,
  2855. 'shipment_province_name': shipmentProvinceName,
  2856. 'shipment_city_id': 0,
  2857. 'shipment_city_name': "",
  2858. 'company_name': company_name,
  2859. 'qualification_number': "",
  2860. 'scrape_date': scrape_date,
  2861. 'min_price': min_price,
  2862. 'number': 1,
  2863. 'sales': "",
  2864. 'inventory': "",
  2865. 'snapshot_url': oss_url or '',
  2866. 'insert_time': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
  2867. 'update_time': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
  2868. 'collect_equipment_account_id': self.collect_equipment_account_id,
  2869. 'collect_region_id': self.collect_region_id,
  2870. 'collect_round': self.collect_round,
  2871. 'task_id': self.task_id or 0,
  2872. }
  2873. # 调用 save_to_database() 的目的是把当前已经校验通过的数据立即持久化,
  2874. # 避免后续页面跳转、返回或异常中断导致采集结果丢失。
  2875. self.save_to_database(save_data)
  2876. return True
  2877. def _error_report(self, data):
  2878. self.report_data.update(data)
  2879. self.scheduler.stop()
  2880. self.scheduler.post_report(self.report_data)
  2881. def _success_report(self, data):
  2882. self.report_data.update(data)
  2883. self.scheduler.post_report(self.report_data)
  2884. def main(self, device_id, search_key_length, keyword_idx):
  2885. # 功能:执行单设备的完整采集主循环,直到达到结束页、采集上限或异常退出条件。
  2886. completed_normally = False
  2887. stop_by_max_count = False
  2888. spider_no = 0
  2889. current_page = self.page
  2890. consecutive_empty_pages = 0
  2891. # 阶段 1:建立设备连接,准备进入搜索页面。
  2892. self.connect_devices(device_id)
  2893. time.sleep(self.get_sleep_time())
  2894. # 统一入口准备:每个任务开局都强制重启 App,清掉上个任务残留的页面/弹窗/搜索词,
  2895. # 避免二次任务带着脏现场跑偏;然后回到主页面并点出搜索框。
  2896. if self.is_pdd_foreground():
  2897. logging.info("当前拼多多在前台,先重启清掉上任务残留,再执行统一入口准备")
  2898. else:
  2899. logging.info("当前不在拼多多可操作页面,先重启,再执行统一入口准备")
  2900. self.prepare_entry_before_enter_target(force_restart=True)
  2901. self.enter_target_page()
  2902. try:
  2903. for idx in range(300):
  2904. self._success_report({'is_finished': 0, 'need_reassign': 0, 'current_page': current_page,
  2905. 'crawled_count': self.click_counts})
  2906. if self.scheduler.end == True:
  2907. break
  2908. print(f'第{current_page}页')
  2909. # self.wr_re("写", self.device_id, self.sort, current_page)
  2910. if spider_no > 30:
  2911. time.sleep(300)
  2912. spider_no = 0
  2913. # 连续命中太多无关商品时,认为当前搜索结果已偏离目标,主动收尾。
  2914. if self.unrelated_data > 15:
  2915. print(f'[{self.program_start_time}]----{self.search_key}----连续超过15个不达标的数据则停止采集')
  2916. print(
  2917. f"[程序启动时间:{self.program_start_time}-----程序结束时间:{self.app_current_time()}]----搜索关键词:{self.search_key}----点击了{self.click_counts}个商品")
  2918. break
  2919. # 达到采集上限后不再继续翻页,直接走正常结束分支。
  2920. if self.is_max_count_reached():
  2921. # 向下滑
  2922. self.swipe_down()
  2923. time.sleep(self.get_sleep_time())
  2924. # 点击搜索框
  2925. click_success = self.click_target_product_by_search_key(fuzzy_match=False)
  2926. if not click_success:
  2927. print(f"关键词「{self.search_key}」商品点击失败")
  2928. break
  2929. print("点击搜索框")
  2930. self.d(className='android.widget.EditText').click()
  2931. time.sleep(self.get_sleep_time())
  2932. break
  2933. # 售罄次数大于4基本就是号废了但是如果下次点击不会出现这种情况就要重置为0
  2934. # 连续多次命中售罄商品时,认为当前账号/结果页已失去采集价值,提前退出。
  2935. if self.sold_out_counts > 4:
  2936. self._error_report(
  2937. {'is_finished': 0, 'need_reassign': 1, 'current_page': current_page, 'exception_type': 5,
  2938. 'crawled_count': self.click_counts})
  2939. print(
  2940. f"[程序启动时间:{self.program_start_time}-----程序结束时间:{self.app_current_time()}]----搜索关键词:{self.search_key}----点击了{self.click_counts}个商品")
  2941. break
  2942. # 阶段 3:获取当前页可见商品卡片,并逐个点击进入详情页采集。
  2943. # 检测"官方旗舰店"区域:如果"进店逛逛"和店铺icon同时存在,
  2944. # 说明列表顶部有旗舰店入口,先下滑跳过该区域再取商品列表。
  2945. if self.d.xpath('//*[@text="进店逛逛"]').exists or \
  2946. self.d.xpath('//*[@text="进店"]').exists:
  2947. print('检测到官方旗舰店区域,向下滑动半屏跳过')
  2948. screen_w = self.d.info.get('displayWidth', 1080)
  2949. screen_h = self.d.info.get('displayHeight', 2400)
  2950. self.d.swipe(
  2951. screen_w // 2,
  2952. int(screen_h * 0.7),
  2953. screen_w // 2,
  2954. int(screen_h * 0.2),
  2955. duration=0.4
  2956. )
  2957. time.sleep(self.get_sleep_time())
  2958. drug_lis = self.get_drug_lis(idx)
  2959. print('数量', len(drug_lis))
  2960. if not drug_lis:
  2961. consecutive_empty_pages += 1
  2962. logging.warning(
  2963. f"main: 第 {current_page} 页未识别到商品,连续空页次数={consecutive_empty_pages}"
  2964. )
  2965. if not self.distinct_target():
  2966. logging.warning("main: 当前疑似不在商品列表页,尝试回退恢复列表页")
  2967. recovered = self.back_to_list_page()
  2968. if not recovered and (
  2969. self.is_pdd_home_page()
  2970. or (self.d(className='android.widget.EditText').exists and not self.d.xpath(
  2971. '//*[@text="筛选"]').exists)
  2972. ):
  2973. logging.warning("main: 已落到首页/搜索输入页,直接重新进入搜索结果页")
  2974. self.enter_target_page()
  2975. current_page = self.page
  2976. consecutive_empty_pages = 0
  2977. continue
  2978. time.sleep(self.get_sleep_time())
  2979. drug_lis = self.get_drug_lis(idx)
  2980. print('恢复后数量', len(drug_lis))
  2981. else:
  2982. logging.warning("main: 仍在列表页但商品列表为空,先做一次轻微下滑恢复")
  2983. self.swipe_down()
  2984. time.sleep(self.get_sleep_time())
  2985. drug_lis = self.get_drug_lis(idx)
  2986. print('下滑恢复后数量', len(drug_lis))
  2987. if drug_lis:
  2988. consecutive_empty_pages = 0
  2989. elif consecutive_empty_pages >= 3:
  2990. logging.warning("main: 连续空页达到阈值,重新进入搜索结果页")
  2991. self.enter_target_page()
  2992. current_page = self.page
  2993. consecutive_empty_pages = 0
  2994. continue
  2995. for idd, drug_one in enumerate(drug_lis):
  2996. print(idd + 1, drug_one.info)
  2997. time.sleep(self.get_sleep_time())
  2998. top = drug_one.info['bounds']['top']
  2999. bottom = drug_one.info['bounds']['bottom']
  3000. _, screen_h = self.get_screen_size()
  3001. if bottom <= int(screen_h * 0.93) and top >= int(screen_h * 0.16):
  3002. drug_one.click()
  3003. self.click_counts += 1
  3004. time.sleep(self.get_sleep_time())
  3005. # 先判断是否售罄次数是否大于4
  3006. if self.sold_out_counts >= 4:
  3007. print(
  3008. f"[程序启动时间:{self.program_start_time}-----程序结束时间:{self.app_current_time()}]----搜索关键词:{self.search_key}----点击了{self.click_counts}个商品")
  3009. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': current_page,
  3010. 'exception_type': 5, 'crawled_count': self.click_counts})
  3011. time.sleep(self.get_sleep_time())
  3012. self.d.press('home')
  3013. stop_by_max_count = True
  3014. break
  3015. # 这里先判断"商品已售罄",是为了尽早放弃无效详情页;
  3016. # 如果不先做这一步,后续详情采集会浪费时间且可能干扰账号状态判断。
  3017. if self.d.xpath('//*[contains(@text, "商品已售罄")]').wait(timeout=5):
  3018. print("======商品已售罄======")
  3019. self.sold_out_counts += 1
  3020. if self.back_to_list_page():
  3021. continue
  3022. # 采集药品信息
  3023. # 进入详情页后的采集与回退是最容易卡死的阶段,需要单独兜底。
  3024. try:
  3025. # 重置商品售罄次数
  3026. self.sold_out_counts = 0
  3027. # 如果仍在列表页,说明点击失效未进入详情页,直接跳过,不要走后续采集和返回逻辑
  3028. if self.distinct_target():
  3029. print("由于网络卡顿或点击失效未能进入详情页,跳过该商品")
  3030. continue
  3031. saved = self.integrate_data_v2()
  3032. # 新加判断逻辑(增加采集速度),如果保存成功数据返回true,然后执行点商品参数的×,否则就执行一次返回
  3033. if saved:
  3034. x_btn = self.d.xpath('//*[@text=""]')
  3035. if x_btn.exists:
  3036. x_btn.click()
  3037. time.sleep(random.uniform(0.5, 1))
  3038. self.d.press("back")
  3039. time.sleep(random.uniform(0.5, 1))
  3040. else:
  3041. if not self.distinct_target():
  3042. self.d.press("back")
  3043. time.sleep(random.uniform(0.5, 0.8))
  3044. else:
  3045. print("采集失败,但当前仍在列表页,跳过按回退键避免乱逛")
  3046. # 检测下是否回退到列表页
  3047. if self.back_to_list_page():
  3048. print('回退到列表页', True)
  3049. else:
  3050. if self.d(className='android.widget.EditText').exists and not self.d.xpath(
  3051. '//*[@text="筛选"]').exists:
  3052. print(f'[{self.app_current_time()}] 已在搜索输入页,直接重新搜索以恢复任务')
  3053. self.enter_target_page()
  3054. elif self.is_pdd_home_page():
  3055. print(f'[{self.app_current_time()}] 已退至首页,重新准备搜索入口以恢复任务')
  3056. self.prepare_entry_before_enter_target(force_restart=False)
  3057. self.enter_target_page()
  3058. else:
  3059. print(f'[{self.app_current_time()}] 回退到列表页失败,尝试重启以恢复当前任务进度')
  3060. self.prepare_entry_before_enter_target(force_restart=True)
  3061. self.enter_target_page()
  3062. break
  3063. time.sleep(self.get_sleep_time())
  3064. spider_no += 1
  3065. if self.is_max_count_reached():
  3066. stop_by_max_count = True
  3067. break
  3068. except Exception as e:
  3069. self.loggerPdd.error(f'采集药品详情数据出错:{e}')
  3070. if not self.back_to_list_page():
  3071. if self.d(className='android.widget.EditText').exists and not self.d.xpath(
  3072. '//*[@text="筛选"]').exists:
  3073. logging.warning(
  3074. f'[{self.app_current_time()}] 已在搜索输入页,直接重新搜索以恢复进度')
  3075. self.enter_target_page()
  3076. elif self.is_pdd_home_page():
  3077. logging.warning(
  3078. f'[{self.app_current_time()}] 已退至首页,重新准备搜索入口以恢复进度')
  3079. self.prepare_entry_before_enter_target(force_restart=False)
  3080. self.enter_target_page()
  3081. else:
  3082. logging.warning(
  3083. f'[{self.app_current_time()}] 回退到列表页失败,尝试重启以恢复当前进度')
  3084. self.prepare_entry_before_enter_target(force_restart=True)
  3085. self.enter_target_page()
  3086. break
  3087. else:
  3088. continue
  3089. if drug_lis:
  3090. consecutive_empty_pages = 0
  3091. # 阶段 4:处理翻页前的收尾条件,包括采集上限、结束页和列表到底。
  3092. if stop_by_max_count:
  3093. break
  3094. # 配置了结束页时,以调用方传入的页边界作为最高优先级退出条件。
  3095. if self.end_page is not None and current_page >= self.end_page:
  3096. break
  3097. if self.d(textStartsWith="抱歉,没有更多商品啦~").exists:
  3098. break
  3099. # 阶段 5:当前页还没触发任何结束条件时,继续滑到下一页。
  3100. print('开始滑入下一页')
  3101. # 修复滑动抖动:使用固定的滑动参数
  3102. screen_width = self.d.info.get('displayWidth', 1080)
  3103. screen_height = self.d.info.get('displayHeight', 2400)
  3104. # 固定滑动参数,不随页数变化
  3105. start_x = screen_width // 2 # 屏幕中心
  3106. start_y = int(screen_height * 0.8) # 从屏幕80%高度开始
  3107. end_y = int(screen_height * 0.2) # 滑动到屏幕20%高度
  3108. self.d.swipe(start_x, start_y, start_x, end_y, duration=random.uniform(0.2, 0.4))
  3109. time.sleep(self.get_sleep_time())
  3110. current_page += 1
  3111. self.page = current_page
  3112. # ========== 新增:停止心跳上报线程 ==========
  3113. # ==========================================
  3114. except Exception as e:
  3115. self.loggerPdd.error(f"Task aborted with exception: {e}", exc_info=True)
  3116. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': current_page, 'exception_type': 5,
  3117. 'crawled_count': self.click_counts})
  3118. return False
  3119. # 阶段 6:根据最终状态做统一收尾,保证任务一定会走到正常或异常结束分支之一。
  3120. if self.scheduler.end == False:
  3121. self._success_report({'is_finished': 1, 'need_reassign': 0, 'current_page': current_page,
  3122. 'crawled_count': self.click_counts})
  3123. if completed_normally:
  3124. self.clear_progress_file()
  3125. return completed_normally
  3126. # pdd
  3127. def main():
  3128. # 功能:启动调度器入口,先立即执行一轮派单,再注册后续轮询。
  3129. interval_seconds = MANUAL_SCHEDULER_INTERVAL_SECONDS if USE_MANUAL_TASKS else SCHEDULER_INTERVAL_SECONDS
  3130. logging.info(f"PDD 调度器启动,轮询间隔 {interval_seconds} 秒")
  3131. dispatch_pending_tasks()
  3132. if USE_MANUAL_TASKS:
  3133. logging.info(f"手动任务模式:启动定时轮询,间隔 {MANUAL_SCHEDULER_INTERVAL_SECONDS} 秒")
  3134. if all_manual_tasks_dispatched() and not has_active_workers():
  3135. logging.info("手动任务模式:全部任务已完成,进程退出")
  3136. return
  3137. schedule_dispatch(MANUAL_SCHEDULER_INTERVAL_SECONDS, manual_scheduled_dispatch_job)
  3138. scheduler_stop_event.wait()
  3139. return
  3140. schedule_dispatch(SCHEDULER_INTERVAL_SECONDS)
  3141. scheduler_stop_event.wait()
  3142. if __name__ == '__main__':
  3143. main()