pdd_new.py 68 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605
  1. import requests
  2. import base64
  3. import uiautomator2 as u2
  4. import time
  5. import sys
  6. import subprocess
  7. import re
  8. import random
  9. import json
  10. from aip import AipOcr
  11. import numpy as np
  12. import cv2
  13. import os
  14. from pdd_config import Config
  15. import logging
  16. from logger import setup_logger
  17. import pymysql
  18. from 拼多多盒数处理脚本.main import extract_box_number
  19. import datetime
  20. import threading
  21. setup_logger("pdd_spider") # 初始化日志
  22. def get_mysql():
  23. return pymysql.connect(
  24. host='120.24.49.2', # 修改后的主机
  25. port=3306, # 添加端口号
  26. user='drug_retrieve', # 修改后的用户名
  27. password='ksCt3xm6chzdkafj', # 修改后的密码
  28. db='drug_retrieve', # 修改后的数据库名
  29. charset='utf8mb4'
  30. )
  31. SCHEDULER_INTERVAL_SECONDS = 600
  32. PLATFORM_PDD = 3
  33. TASK_STATUS_PENDING = 1
  34. DEVICE_STATUS_IDLE = 0
  35. DEFAULT_MAX_COUNTS_LIMIT = 300
  36. dispatch_lock = threading.Lock()
  37. running_task_ids = set()
  38. running_device_ids = set()
  39. worker_threads = {}
  40. scheduler_stop_event = threading.Event()
  41. scheduler_timer = None
  42. def parse_optional_int(value, default=None):
  43. if value in (None, ""):
  44. return default
  45. try:
  46. return int(value)
  47. except (TypeError, ValueError):
  48. return default
  49. def fetch_pending_tasks():
  50. conn = None
  51. try:
  52. conn = get_mysql()
  53. with conn.cursor() as cursor:
  54. sql = """
  55. SELECT *
  56. FROM retrieve_collect_task_allocate
  57. WHERE platform = %s AND status = %s
  58. ORDER BY id ASC
  59. """
  60. cursor.execute(sql, (PLATFORM_PDD, TASK_STATUS_PENDING))
  61. return cursor.fetchall()
  62. except Exception as e:
  63. logging.exception(f"读取待执行任务失败: {e}")
  64. return []
  65. finally:
  66. if conn:
  67. conn.close()
  68. def fetch_idle_device_by_equipment_id(equipment_id):
  69. conn = None
  70. try:
  71. conn = get_mysql()
  72. with conn.cursor() as cursor:
  73. sql = """
  74. SELECT *
  75. FROM retrieve_collect_equipment
  76. WHERE name LIKE %s AND id = %s AND status = %s
  77. LIMIT 1
  78. """
  79. cursor.execute(sql, ('%pdd%', equipment_id, DEVICE_STATUS_IDLE))
  80. return cursor.fetchone()
  81. except Exception as e:
  82. logging.exception(f"读取空闲设备失败 equipment_id={equipment_id}: {e}")
  83. return None
  84. finally:
  85. if conn:
  86. conn.close()
  87. def build_task_payload(task_row, device_row):
  88. start_page = parse_optional_int(task_row[9] if len(task_row) > 9 else None, 0)
  89. end_page = parse_optional_int(task_row[10] if len(task_row) > 10 else None, None)
  90. max_counts_limit = parse_optional_int(
  91. task_row[11] if len(task_row) > 11 else None,
  92. DEFAULT_MAX_COUNTS_LIMIT
  93. )
  94. return {
  95. "task_id": task_row[0],
  96. "equipment_id": task_row[2],
  97. "enterprise_id": task_row[3],
  98. "platform": task_row[4],
  99. "title_key": task_row[5],
  100. "spec_list": task_row[6],
  101. "brand": task_row[7],
  102. "search_key": f"{task_row[7]}{task_row[5]}",
  103. "save_search_key": f"{task_row[7]}{task_row[5]}",
  104. "start_page": start_page,
  105. "end_page": end_page,
  106. "max_counts_limit": max_counts_limit,
  107. "sort": "升序",
  108. "device_id": device_row[2],
  109. "task_row": task_row,
  110. }
  111. def fetch_runnable_task_payloads():
  112. tasks = fetch_pending_tasks()
  113. if not tasks:
  114. logging.info("当前没有待执行任务")
  115. return []
  116. payloads = []
  117. reserved_equipment_ids = set()
  118. for task_row in tasks:
  119. task_id = task_row[0]
  120. equipment_id = task_row[2]
  121. with dispatch_lock:
  122. if task_id in running_task_ids:
  123. continue
  124. if equipment_id in reserved_equipment_ids:
  125. continue
  126. device_row = fetch_idle_device_by_equipment_id(equipment_id)
  127. if not device_row:
  128. logging.info(f"任务 {task_id} 对应设备 {equipment_id} 当前不空闲,跳过本轮")
  129. continue
  130. device_id = device_row[2]
  131. with dispatch_lock:
  132. if device_id in running_device_ids:
  133. logging.info(f"设备 {device_id} 已在本进程执行任务,跳过任务 {task_id}")
  134. continue
  135. running_task_ids.add(task_id)
  136. running_device_ids.add(device_id)
  137. reserved_equipment_ids.add(equipment_id)
  138. payloads.append(build_task_payload(task_row, device_row))
  139. return payloads
  140. def cleanup_finished_workers():
  141. dead_threads = []
  142. with dispatch_lock:
  143. for device_id, thread in worker_threads.items():
  144. if not thread.is_alive():
  145. dead_threads.append(device_id)
  146. for device_id in dead_threads:
  147. worker_threads.pop(device_id, None)
  148. def run_task_worker(task_payload):
  149. task_id = task_payload["task_id"]
  150. device_id = task_payload["device_id"]
  151. pdd = None
  152. try:
  153. logging.info(f"[任务 {task_id}] 开始执行,设备: {device_id}")
  154. print(task_payload)
  155. pdd = PDD(
  156. task_payload["search_key"],
  157. device_id,
  158. title_key=task_payload.get("title_key"),
  159. spec_list=task_payload.get("spec_list"),
  160. brand=task_payload.get("brand", ""),
  161. save_search_key=task_payload.get("save_search_key"),
  162. start_page=task_payload.get("start_page"),
  163. end_page=task_payload.get("end_page"),
  164. max_counts_limit=task_payload.get("max_counts_limit"),
  165. direct_shop_lookup=task_payload.get("direct_shop_lookup", False),
  166. sort=task_payload.get("sort"),
  167. platform=task_payload.get("platform"),
  168. task_id=task_payload.get("task_id"),
  169. enterprise_id=task_payload.get("enterprise_id"),
  170. )
  171. completed_normally = pdd.main(device_id, 1, 0)
  172. if completed_normally:
  173. logging.info(f"[任务 {task_id}] 执行完成,设备: {device_id}")
  174. else:
  175. logging.info(f"[任务 {task_id}] 已结束,设备: {device_id}")
  176. except Exception as e:
  177. end_page = task_payload.get("start_page")
  178. if pdd is not None:
  179. end_page = getattr(pdd, "page", end_page)
  180. pdd.finish_task_abnormally(end_page, f"任务执行异常: {e}")
  181. else:
  182. report_api(task_id, end_page=end_page, start=4, end_time=int(time.time()),finish_status=0)
  183. logging.exception(f"[任务 {task_id}] 执行异常,设备: {device_id},错误: {e}")
  184. finally:
  185. with dispatch_lock:
  186. running_task_ids.discard(task_id)
  187. running_device_ids.discard(device_id)
  188. worker_threads.pop(device_id, None)
  189. def dispatch_pending_tasks():
  190. cleanup_finished_workers()
  191. task_payloads = fetch_runnable_task_payloads()
  192. if not task_payloads:
  193. return
  194. for task_payload in task_payloads:
  195. device_id = task_payload["device_id"]
  196. try:
  197. thread = threading.Thread(
  198. target=run_task_worker,
  199. args=(task_payload,),
  200. daemon=True,
  201. name=f"pdd-{device_id}",
  202. )
  203. with dispatch_lock:
  204. worker_threads[device_id] = thread
  205. thread.start()
  206. logging.info(f"[任务 {task_payload['task_id']}] 已分发到设备 {device_id}")
  207. except Exception:
  208. with dispatch_lock:
  209. running_task_ids.discard(task_payload["task_id"])
  210. running_device_ids.discard(device_id)
  211. worker_threads.pop(device_id, None)
  212. raise
  213. def schedule_dispatch(delay_seconds=SCHEDULER_INTERVAL_SECONDS):
  214. global scheduler_timer
  215. if scheduler_stop_event.is_set():
  216. return
  217. scheduler_timer = threading.Timer(delay_seconds, scheduled_dispatch_job)
  218. scheduler_timer.daemon = False
  219. scheduler_timer.name = "pdd-scheduler"
  220. scheduler_timer.start()
  221. def scheduled_dispatch_job():
  222. try:
  223. dispatch_pending_tasks()
  224. except Exception as e:
  225. logging.exception(f"PDD 定时调度异常: {e}")
  226. finally:
  227. schedule_dispatch(SCHEDULER_INTERVAL_SECONDS)
  228. def report_api(task_id,page=None,start=None,end_page=None,end_time=None,finish_status=None):
  229. params = {
  230. "collect_task_allocate_id": task_id,
  231. "statr_page":page if page is not None else '',
  232. "end_page": end_page if end_page is not None else '',
  233. "status": start,
  234. "finish_status": finish_status if finish_status is not None else 0,
  235. "start_time": int(time.time()),
  236. "end_time": end_time if end_time is not None else '',
  237. }
  238. print(params)
  239. url = "http://schedule.dfwy.tech/api/collect_equipment_execute/result_report"
  240. res = requests.get(url, params=params, timeout=20)
  241. print(res.text)
  242. # 获取滑块验证中滑块需要移动的距离
  243. def slide_verify(img_path):
  244. with open(img_path, 'rb') as f:
  245. b = base64.b64encode(f.read()).decode() ## 图片二进制流base64字符串
  246. url = "http://api.jfbym.com/api/YmServer/customApi"
  247. data = {
  248. ## 关于参数,一般来说有3个;不同类型id可能有不同的参数个数和参数名,找客服获取
  249. "token": "1nDVocTE2mJ0yLEYb2sZJ5uUY2VIEoGTkIpW44X7Kgk",
  250. "type": "22222",
  251. "image": b,
  252. }
  253. _headers = {
  254. "Content-Type": "application/json"
  255. }
  256. response = requests.request("POST", url, headers=_headers, json=data).json()
  257. print(response)
  258. if response.get("msg") == "识别成功":
  259. # 获取 data 中的 data 字段
  260. result = response.get("data", {}).get("data")
  261. if result:
  262. print(result) # 输出结果
  263. else:
  264. print("无法获取数据")
  265. else:
  266. print("识别未成功")
  267. return result
  268. class PDD:
  269. def __init__(
  270. self,
  271. search_key,
  272. device_id,
  273. title_key=None,
  274. spec_list=None,
  275. brand="",
  276. save_search_key=None,
  277. start_page=0,
  278. end_page=None,
  279. max_counts_limit=None,
  280. direct_shop_lookup=False,
  281. sort=None,
  282. platform = None,
  283. task_id = None,
  284. enterprise_id=None,
  285. ):
  286. self.package_name = 'com.xunmeng.pinduoduo'
  287. self.APP_ID = '116857964'
  288. self.API_KEY = '1gAzACJOAr7BeILKqkqPOETh'
  289. self.SECRET_KEY = 'ZNArANb9GwJYgLKg4EfYhukKBfPdl1n3'
  290. self.client = AipOcr(self.APP_ID, self.API_KEY, self.SECRET_KEY)
  291. self.table_name = "retrieve_scrape_data" # "pdd_drug"
  292. self.shop_table_name = "retrieve_scrape_data" # "pdd_shop_info"
  293. self.loggerPdd = logging.getLogger()
  294. self.clipboard = "" # 初始化剪切板的内容为空
  295. self.enterprise_id = enterprise_id
  296. self.task_id = task_id
  297. self.platform = platform
  298. self.sort = sort
  299. self.sort_key = 0
  300. self.search_key = search_key # 参苓健脾胃颗粒 香砂平胃颗粒 舒肝颗粒 清肺化痰丸
  301. self.title_key = title_key if title_key is not None else search_key
  302. self.spec_list = self._normalize_rule_list(spec_list)
  303. self.brand = brand
  304. self.save_search_key = save_search_key or search_key
  305. self.start_page = max(parse_optional_int(start_page, 0), 0)
  306. self.end_page = parse_optional_int(end_page, None)
  307. self.max_counts_limit = max_counts_limit
  308. self.direct_shop_lookup = direct_shop_lookup
  309. self.unrelated_data = 0 # 无关数据数量
  310. self.device_id = device_id
  311. self.page = self.start_page
  312. if self.end_page is not None and self.end_page < self.start_page:
  313. self.end_page = self.start_page
  314. # 统计售罄数量
  315. self.sold_out_counts = 0
  316. # 程序启动时间
  317. self.program_start_time = self.app_start_time()
  318. # 统计商品数量
  319. # 最大量数据阈值
  320. self.max_counts = 0
  321. # 统计点击商品的次数
  322. self.click_counts = 0
  323. # 商品在列表的位置
  324. self.search_key_loc = 0
  325. self.finish_reported = False
  326. # oss配置
  327. self.oss_config = {
  328. "access_key_id": Config.access_key_id,
  329. "access_key_secret": Config.access_key_secret,
  330. "endpoint": Config.endpoint, # 例: oss-cn-beijing.aliyuncs.com
  331. "bucket_name": Config.bucket_name,
  332. "oss_prefix": Config.oss_prefix # OSS中存放截图的前缀(虚拟文件夹)
  333. }
  334. # 异常处理
  335. def wr_re(self, mod, device_id, sort=None, page=None):
  336. file_path = f'./ycwj/{device_id}_{self.title_key}.txt'
  337. if mod == "写":
  338. try:
  339. data = {
  340. "page": page if page else "",
  341. "sort": sort if sort else "",
  342. }
  343. os.makedirs(os.path.dirname(file_path), exist_ok=True)
  344. with open(file_path, 'w', encoding='utf-8') as f:
  345. json.dump(data, f, ensure_ascii=False, indent=2)
  346. print(f"进度保存成功:{sort},{page}页")
  347. except Exception as e:
  348. print("保存进度失败")
  349. elif mod == "读":
  350. try:
  351. if not os.path.exists(file_path):
  352. return None
  353. with open(file_path, 'r', encoding='utf-8') as f:
  354. data = json.load(f)
  355. print(self.sort)
  356. if self.sort and self.sort_key == 0:
  357. self.li_or_lo(self.sort)
  358. if data['page'] != '':
  359. progress_page = int(data['page'])
  360. self.page = max(progress_page, self.start_page)
  361. self.scroll_to_target_page(self.page)
  362. else:
  363. return None
  364. return data
  365. except Exception as e:
  366. print(f"读取进度失败", e)
  367. return None
  368. elif mod == "删":
  369. try:
  370. if os.path.exists(file_path):
  371. os.remove(file_path)
  372. print(f"进度文件已删除:{file_path}")
  373. except Exception as e:
  374. print(f"删除进度文件失败:{e}")
  375. return None
  376. def clear_progress_file(self):
  377. # self.wr_re("删", self.device_id, self.sort)
  378. pass
  379. def is_max_count_reached(self):
  380. return bool(self.max_counts_limit and self.max_counts >= self.max_counts_limit)
  381. def scroll_to_target_page(self, target_page):
  382. target_page = int(target_page or 0)
  383. if target_page <= 0:
  384. return
  385. for _ in range(target_page):
  386. end_y = 300
  387. self.d.swipe(200, 1400, 200, end_y, 0.4)
  388. time.sleep(self.get_sleep_time())
  389. def finish_task_normally(self, end_page, reason):
  390. if not self.finish_reported:
  391. report_api(self.task_id, end_page=end_page, start=3, end_time=int(time.time()),finish_status=1)
  392. self.finish_reported = True
  393. print(reason)
  394. return True
  395. def finish_task_abnormally(self, end_page, reason, finish_status=0):
  396. if not self.finish_reported:
  397. report_api(
  398. self.task_id,
  399. end_page=end_page,
  400. start=4,
  401. end_time=int(time.time()),
  402. finish_status=finish_status
  403. )
  404. self.finish_reported = True
  405. print(reason)
  406. return False
  407. def finish_task_with_max_count(self, end_page):
  408. return self.finish_task_normally(
  409. end_page,
  410. f"达到最大采集数量 {self.max_counts_limit},当前已采集 {self.max_counts} 条,停止任务"
  411. )
  412. # 排序
  413. def li_or_lo(self, key):
  414. if key == "升序":
  415. self.sort_key += 1
  416. self.d.xpath('//*[@text="价格"]').click()
  417. n = self.d.xpath('//*[@text="总价低到高"]')
  418. if n.exists:
  419. n.click()
  420. time.sleep(self.get_sleep_time())
  421. if key == "降序":
  422. self.sort_key += 1
  423. self.d.xpath('//*[@text="价格"]').click()
  424. n = self.d.xpath('//*[@text="单粒价格低到高"]')
  425. if n:
  426. n.click()
  427. else:
  428. self.d.xpath('//*[@text="价格"]').click()
  429. # 返回列表页
  430. def back_to_list_page(self):
  431. for i in range(10):
  432. if self.distinct_target():
  433. return True
  434. print(f'第{i}次尝试退回到列表页')
  435. self.swipe_back(1)
  436. time.sleep(1)
  437. print('页面出错,没有退回到列表页')
  438. return False
  439. def get_drug_lis(self, idx):
  440. if idx == 0:
  441. drug_lis = self.d.xpath(
  442. '//*[@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()
  443. else:
  444. for i in range(1, 6):
  445. drug_lis = self.d.xpath(
  446. 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()
  447. if drug_lis:
  448. break
  449. return drug_lis
  450. # 代码运行那时候的时间
  451. def app_current_time(self):
  452. return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  453. def slide_link(self):
  454. value_tag = None
  455. if self.d.xpath('//*[@text="微信"]').exists:
  456. value_tag = self.d.xpath('//*[@text="微信"]').info['bounds']
  457. self.d.swipe(400, value_tag['top'], 100, value_tag['top'], 0.3)
  458. return
  459. if self.d.xpath('//*[@text="朋友圈"]').exists:
  460. value_tag = self.d.xpath('//*[@text="朋友圈"]').info['bounds']
  461. self.d.swipe(400, value_tag['top'], 100, value_tag['top'], 0.3)
  462. return
  463. if self.d.xpath('//*[@text="QQ好友"]').exists:
  464. value_tag = self.d.xpath('//*[@text="QQ好友"]').info['bounds']
  465. self.d.swipe(400, value_tag['top'], 100, value_tag['top'], 0.3)
  466. return
  467. def app_start_time(self):
  468. """
  469. 获取app启动时间
  470. :return:
  471. """
  472. return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  473. def stop_app(self):
  474. self.d.app_stop(self.package_name)
  475. time.sleep(5)
  476. def start_app(self):
  477. self.d.app_start(self.package_name)
  478. time.sleep(5)
  479. def restart_app(self):
  480. """
  481. 重启app
  482. :return:
  483. """
  484. self.stop_app()
  485. self.start_app()
  486. @staticmethod
  487. def get_sleep_time():
  488. return random.randint(1, 2)
  489. # return random.randint(5, 8)
  490. @staticmethod
  491. def get_current_date():
  492. return datetime.datetime.now().strftime('%Y/%m/%d')
  493. @staticmethod
  494. def _normalize_rule_list(value):
  495. if value is None:
  496. return []
  497. if isinstance(value, (list, tuple, set)):
  498. raw_values = value
  499. else:
  500. raw_values = [value]
  501. result = []
  502. for item in raw_values:
  503. item_str = str(item).strip()
  504. if item_str:
  505. result.append(item_str)
  506. return result
  507. @staticmethod
  508. def _normalize_match_text(value):
  509. return re.sub(r'\s+', '', str(value or '')).lower()
  510. def _match_any_keyword(self, text, keywords):
  511. keyword_list = self._normalize_rule_list(keywords)
  512. if not keyword_list:
  513. return True
  514. normalized_text = self._normalize_match_text(text)
  515. return any(self._normalize_match_text(keyword) in normalized_text for keyword in keyword_list)
  516. def is_link_spec_useful(self, product_title, specifications=''):
  517. if not self.spec_list:
  518. return True
  519. title_text = self._normalize_match_text(product_title)
  520. spec_text = self._normalize_match_text(specifications)
  521. for spec in self.spec_list:
  522. normalized_spec = self._normalize_match_text(spec)
  523. if normalized_spec in title_text or normalized_spec in spec_text:
  524. return True
  525. return False
  526. def is_link_useful(self, product_title, specifications=''):
  527. if not self._match_any_keyword(product_title, self.title_key):
  528. print(f"当前商品名称:{product_title} 不包含{self.title_key}关键字")
  529. return False
  530. if not self._match_any_keyword(product_title, self.brand):
  531. print(f"当前商品名称:{product_title} 不包含{self.brand}品牌")
  532. return False
  533. if not self.is_link_spec_useful(product_title, specifications):
  534. print(f"当前商品名称:{product_title} 不包含{self.spec_list}品规")
  535. return False
  536. return True
  537. def remove_watermark(self, img_path):
  538. """
  539. 图片去水印(将水印部分变成白色背景)并将数据转化为二进制数据
  540. :param img_path: 图片路径
  541. :return: 二进制图片数据
  542. """
  543. img = cv2.imdecode(np.fromfile(img_path, dtype=np.uint8), -1)
  544. endswith = os.path.splitext(img_path)[1]
  545. new = np.clip(1.4057577998008846 * img - 38.33089999653017, 0, 255).astype(np.uint8)
  546. _, img_binary = cv2.imencode(endswith, new)
  547. return img_binary
  548. def get_shop_name(self):
  549. """
  550. 获取店铺名
  551. :return:
  552. """
  553. try:
  554. xpath = '//*[@text="进店"]/preceding-sibling::android.view.ViewGroup/android.widget.LinearLayout/android.widget.TextView'
  555. if self.d.xpath(xpath).exists:
  556. shop_name = self.d.xpath(xpath).text
  557. self.loggerPdd.info(f'1-获取到店铺名:{shop_name}')
  558. else:
  559. # 进入店铺新页面
  560. shop_btn_xpath = '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]'
  561. if self.d.xpath(shop_btn_xpath).exists:
  562. self.d.xpath(shop_btn_xpath).click()
  563. time.sleep(1)
  564. # self.d.xpath('//*[@text="店铺"]').click()
  565. xpath_shop_name = '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.widget.LinearLayout[1]/android.widget.RelativeLayout[1]/android.widget.LinearLayout[1]/android.support.v7.widget.RecyclerView[1]/android.widget.RelativeLayout[1]/android.view.ViewGroup[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.TextView[1]'
  566. if self.d.xpath(xpath_shop_name).exists:
  567. shop_name = self.d.xpath(xpath_shop_name).text
  568. self.loggerPdd.info(f'2-获取到店铺名:{shop_name}')
  569. else:
  570. shop_name = ''
  571. self.loggerPdd.info(f'3-获取到店铺名:{shop_name}')
  572. self.swipe_back(1) #
  573. else:
  574. shop_name = ''
  575. self.loggerPdd.info('4-因为shop_btn_xpath不存在,获取到店铺名为空')
  576. # time.sleep(10000)
  577. return shop_name
  578. except Exception as e:
  579. print(f'获取店铺名出错:{e}')
  580. self.loggerPdd.error(f'获取店铺名出错:{e}')
  581. return None
  582. def save_to_database(self, data):
  583. print(f'保存数据到数据库:{data}')
  584. max_retries = 5
  585. for attempt in range(max_retries):
  586. conn = None
  587. try:
  588. conn = get_mysql()
  589. with conn.cursor() as cur:
  590. add_sql = """
  591. INSERT INTO retrieve_scrape_data (
  592. enterprise_id, platform_id, platform_item_id, province_id, city_id,
  593. province_name, city_name, area_info, product_name, product_specs,
  594. one_box_price, manufacture_date, expiry_date, manufacturer, approval_number,
  595. is_sold_out, online_posting_count, continuous_listing_count, link_url,
  596. store_name, store_url, shipment_province_id, shipment_province_name,
  597. shipment_city_id, shipment_city_name, company_name, qualification_number,
  598. scrape_date, min_price, number, sales, inventory, snapshot_url
  599. ) VALUES (
  600. %s, %s, %s, %s, %s,
  601. %s, %s, %s, %s, %s,
  602. %s, %s, %s, %s, %s,
  603. %s, %s, %s, %s,
  604. %s, %s, %s, %s,
  605. %s, %s, %s, %s,
  606. %s, %s, %s, %s, %s, %s
  607. )
  608. """
  609. cur.execute(add_sql, (
  610. data['enterprise_id'],
  611. data['platform_id'],
  612. data['platform_item_id'],
  613. data['province_id'],
  614. data['city_id'],
  615. data['province_name'],
  616. data['city_name'],
  617. data['area_info'],
  618. data['product_name'],
  619. data['product_specs'],
  620. data['one_box_price'],
  621. data['manufacture_date'],
  622. data['expiry_date'],
  623. data['manufacturer'],
  624. data['approval_number'],
  625. data['is_sold_out'],
  626. data['online_posting_count'],
  627. data['continuous_listing_count'],
  628. data['link_url'],
  629. data['store_name'],
  630. data['store_url'],
  631. data['shipment_province_id'],
  632. data['shipment_province_name'],
  633. data['shipment_city_id'],
  634. data['shipment_city_name'],
  635. data['company_name'],
  636. data['qualification_number'],
  637. data['scrape_date'],
  638. data['min_price'],
  639. data['number'],
  640. data['sales'],
  641. data['inventory'],
  642. data['snapshot_url'],
  643. ))
  644. conn.commit()
  645. self.max_counts += 1
  646. print(f"存入数据库成功,当前已采集 {self.max_counts} 条")
  647. return True
  648. except Exception as e:
  649. print(f'保存数据库异常 (尝试 {attempt + 1}/{max_retries}): {e}')
  650. if conn:
  651. conn.rollback()
  652. conn.close()
  653. if attempt == max_retries - 1:
  654. print("达到最大重试次数,保存失败")
  655. return False
  656. time.sleep(2)
  657. def click_target_product_by_search_key(self, fuzzy_match=False, timeout=10):
  658. """
  659. 动态匹配self.search_key对应的商品并点击
  660. :param fuzzy_match: 是否模糊匹配(应对商品名带额外后缀/前缀的情况) 不模糊匹配
  661. :param timeout: 等待元素出现的超时时间(秒)
  662. :return: 点击是否成功(bool)
  663. """
  664. try:
  665. # 1. 定义定位条件(动态使用self.search_key)
  666. if fuzzy_match:
  667. # 模糊匹配:包含search_key即可(推荐,适配搜索结果商品名略有差异)
  668. locator = self.d(textContains=self.search_key)
  669. print(f"🔍 模糊匹配商品:包含「{self.search_key}」的元素")
  670. else:
  671. # 精确匹配:商品名与search_key完全一致
  672. locator = self.d(text=self.search_key)
  673. print(f"🔍 精确匹配商品:「{self.search_key}」")
  674. # 2. 等待元素出现(核心:避免元素未加载就点击)
  675. if locator.wait(timeout=timeout):
  676. print(f"✅ 找到匹配的商品,准备点击")
  677. # 执行点击(优先点击可点击的元素)
  678. locator.click()
  679. print(f"✅ 成功点击「{self.search_key}」对应的商品")
  680. # 点击后等待页面加载
  681. time.sleep(self.get_sleep_time())
  682. return True
  683. else:
  684. print(f"❌ 滑动后仍未找到「{self.search_key}」对应的商品")
  685. return False
  686. except Exception as e:
  687. print(f"❌ 点击「{self.search_key}」对应商品时异常:{e}")
  688. return False
  689. def swipe_down(self):
  690. """
  691. 下滑(模拟真人操作,抗风控+设备适配+容错)
  692. 核心:起点在屏幕上方,终点在屏幕下方(和上滑相反)
  693. :return: None
  694. """
  695. try:
  696. # 1. 获取屏幕尺寸(兼容不同设备,给默认值避免获取失败)
  697. screen_width = self.d.info.get('displayWidth', 1080) # 默认1080px宽度
  698. screen_height = self.d.info.get('displayHeight', 2400) # 默认2400px高度
  699. # 2. 随机滑动时长(0.1~0.3秒,避免固定值被风控,且不设0秒)
  700. duration_rate = random.uniform(0.1, 0.3)
  701. # 3. 计算滑动坐标(用屏幕比例,适配所有设备)
  702. start_x = screen_width // 2 # 水平居中(和上滑一致,符合真人操作习惯)
  703. start_y = screen_height * 0.2 # 起点:屏幕20%高度(上方偏下)
  704. end_y = screen_height * 0.8 # 终点:屏幕80%高度(下方偏上)
  705. # 强制确保起点y < 终点y(必为向下滑,避免逻辑错误)
  706. start_y, end_y = min(start_y, end_y - 10), max(end_y, start_y + 10)
  707. # 4. 核心向下滑动操作
  708. self.d.swipe(start_x, start_y, start_x, end_y, duration=duration_rate)
  709. # 滑动后全局等待(确保页面加载,避免元素定位失败)
  710. time.sleep(self.get_sleep_time())
  711. except Exception as e:
  712. # 异常捕获:避免设备断开/滑动失败导致程序崩溃
  713. print(f"向下滑动失败:{e}")
  714. # 兜底方案:用固定坐标重试(适配主流1080x2400设备)
  715. self.d.swipe(540, 480, 540, 1920, duration=0.2)
  716. time.sleep(self.get_sleep_time())
  717. def swipe_up(self):
  718. """
  719. 上滑
  720. :return:
  721. """
  722. screen_width = self.d.info['displayWidth']
  723. screen_height = self.d.info['displayHeight']
  724. duration_rate = random.uniform(0, 0.3)
  725. self.d.swipe(screen_width // 2, screen_height - 100, screen_width // 2, 100, duration=duration_rate)
  726. no = random.uniform(0, 1)
  727. if no > 0.85:
  728. # 有的时候卡着 再稍微往上滑一点点
  729. self.d.swipe_ext("up", 0.1)
  730. time.sleep(self.get_sleep_time())
  731. def swipe_back(self, no):
  732. """
  733. 返回
  734. :param no: 回退次数
  735. :return:
  736. """
  737. if not self.distinct_target():
  738. for idx in range(no):
  739. self.d.press('back')
  740. time.sleep(self.get_sleep_time())
  741. def drug_price(self):
  742. """
  743. 获取药品价格
  744. :return:
  745. """
  746. try:
  747. xpath = '//*[@text="¥"]/following-sibling::android.widget.TextView[1]'
  748. price_str = self.d.xpath(xpath).text
  749. price = float(re.search(r'[\d\.]+', price_str).group())
  750. print(f'获取到价格:{price}')
  751. return float(price)
  752. except Exception as e:
  753. print(f'提取价格出错-->{e}')
  754. return None
  755. def drug_price_ex(self):
  756. price_str = '' # 价格初始化
  757. ext = '' # 初始化已选择的信息
  758. price = ''
  759. # 这是点击进入品规的按钮
  760. 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()]'
  761. 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()]'
  762. # 调试
  763. # test_button = self.d.xpath(button_xpath_1).exists
  764. # print(test_button)
  765. # test_button_2 = self.d.xpath(button_xpath_2).exists
  766. # print(test_button_2)
  767. # time.sleep(1000)
  768. # if self.d.xpath('//*[@text="发起拼单"]').exists:
  769. # self.d.xpath('//*[@text="发起拼单"]').click()
  770. # elif self.d.xpath('//*[@text="去复诊开药"]').exists:
  771. # self.d.xpath('//*[@text="去复诊开药"]').click()
  772. if self.d.xpath(button_xpath_1).exists:
  773. self.d.xpath(button_xpath_1).click()
  774. elif self.d.xpath(button_xpath_2).exists:
  775. self.d.xpath(button_xpath_2).click()
  776. else:
  777. print("button1 and button_2 all not exist")
  778. return price, ext
  779. select_xpath_1 = '//*[@resource-id="android:id/content"]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.TextView[last()]'
  780. 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()]'
  781. 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()]'
  782. 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]'
  783. price_xpath_1 = '//*[@resource-id="android:id/content"]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.TextView[1]'
  784. 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]'
  785. 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]'
  786. if self.d.xpath(select_xpath_1).exists:
  787. text1 = self.d.xpath(select_xpath_1).text
  788. print(f"select_xpath_1--text1={text1}")
  789. if '已选' in text1:
  790. if self.d.xpath(price_xpath_1).exists:
  791. price_str = self.d.xpath(price_xpath_1).text
  792. print(f"select_xpath_1--price_str-1={price_str}")
  793. else:
  794. print("select_xpath_1--price_xpath_1-1 not exist")
  795. ext = text1
  796. elif '请选择' in text1:
  797. # 需要再下面点击选择
  798. 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()]'
  799. scroll_xpath_2 = ''
  800. if self.d.xpath(scroll_xpath_1).exists:
  801. self.d.xpath(scroll_xpath_1).click()
  802. time.sleep(2) # 延时2秒钟,选择了之后价格会刷新
  803. if self.d.xpath(select_xpath_1).exists:
  804. text2 = self.d.xpath(select_xpath_1).text
  805. if '已选' in text2:
  806. print(f"select_xpath_1--已选择2:text2={text2}")
  807. if self.d.xpath(price_xpath_1).exists:
  808. price_str = self.d.xpath(price_xpath_1).text
  809. print(f"select_xpath_1--price_str-2={price_str}")
  810. else:
  811. print("select_xpath_1--price_xpath_1-2 not exist")
  812. ext = text2
  813. else:
  814. print("select_xpath_1--scroll_xpath_1 not exist")
  815. elif self.d.xpath(select_xpath_2).exists:
  816. text1 = self.d.xpath(select_xpath_2).text
  817. print(f"xpath2--text1={text1}")
  818. if '已选' in text1:
  819. ext = text1
  820. if self.d.xpath(price_xpath_2).exists:
  821. price_str = self.d.xpath(price_xpath_2).text
  822. print(f"select_xpath_2--price_str-2={price_str}")
  823. else:
  824. print("select_xpath_2--price_xpath_2-1 not exist")
  825. elif '请选择' in text1:
  826. print('come in here')
  827. # 需要再下面点击选择
  828. 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]'
  829. if self.d.xpath(scroll_xpath_1).exists:
  830. print("scroll_xpath_1 exists")
  831. self.d.xpath(scroll_xpath_1).click()
  832. time.sleep(2) # 延时2秒钟,选择了之后价格可能会刷新
  833. if self.d.xpath(select_xpath_2).exists:
  834. text2 = self.d.xpath(select_xpath_2).text
  835. if '已选' in text2:
  836. ext = text2
  837. print(f"select_xpath_2--已选择2:text2={text2}")
  838. if self.d.xpath(price_xpath_2).exists:
  839. price_str = self.d.xpath(price_xpath_2).text
  840. print(f"select_xpath_2--price_str-2={price_str}")
  841. else:
  842. print("select_xpath_2--price_xpath_2-2 not exist")
  843. else:
  844. print("scroll_xpath_1 not exists")
  845. else:
  846. print("not exist 请选择 or 已选")
  847. elif self.d.xpath(select_xpath_3).exists:
  848. text1 = self.d.xpath(select_xpath_3).text
  849. print(f"xpath3--text1-1={text1}")
  850. if ('请选择' not in text1) and ('已选' not in text1):
  851. text1 = self.d.xpath(select_xpath_3_2).text
  852. print(f"xpath3--text1-2={text1}")
  853. if '已选' in text1:
  854. ext = text1
  855. if self.d.xpath(price_xpath_3).exists:
  856. price_str = self.d.xpath(price_xpath_3).text
  857. print(f"select_xpath_3--price_str-3-3-1={price_str}")
  858. else:
  859. print("select_xpath_3--price_xpath_3-3-1 not exist")
  860. elif '请选择' in text1:
  861. print('come in here')
  862. # 需要再下面点击选择
  863. 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]'
  864. 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]'
  865. if self.d.xpath(scroll_xpath_1).exists:
  866. print("scroll_xpath_1 exists")
  867. self.d.xpath(scroll_xpath_1).click()
  868. time.sleep(2) # 延时2秒钟,选择了之后价格可能会刷新
  869. if self.d.xpath(select_xpath_3).exists:
  870. text2 = self.d.xpath(select_xpath_3).text
  871. if '已选' in text2:
  872. ext = text2
  873. print(f"select_xpath_3--已选择2:text2={text2}")
  874. if self.d.xpath(price_xpath_3).exists:
  875. price_str = self.d.xpath(price_xpath_3).text
  876. print(f"select_xpath_3--price_str-3-2={price_str}")
  877. else:
  878. print("select_xpath_3--price_xpath_3-3-2 not exist")
  879. elif self.d.xpath(recycler_view_xpath).exists:
  880. self.d.xpath(recycler_view_xpath).click()
  881. time.sleep(2) # 延时2秒钟,选择了之后价格可能会刷新
  882. if self.d.xpath(select_xpath_3).exists:
  883. text2 = self.d.xpath(select_xpath_3).text
  884. if '已选' in text2:
  885. ext = text2
  886. print(f"select_xpath_3--已选择2:text2={text2}")
  887. if self.d.xpath(price_xpath_3).exists:
  888. price_str = self.d.xpath(price_xpath_3).text
  889. print(f"select_xpath_3--price_str-3-3={price_str}")
  890. else:
  891. print("select_xpath_3--price_xpath_3-3-3 not exist")
  892. else:
  893. print("scroll_xpath_1 not exists")
  894. else:
  895. print(f"xpath3--text1-不包含请选择和已选择")
  896. else:
  897. print("select_xpath_1 and select_xpath_2 and select_xpath_3 all not exist")
  898. if price_str:
  899. # price = float(re.search('[\d\.]+', price_str).group())
  900. match = re.search(r'¥([\d\.]+)', price_str)
  901. if match:
  902. price = float(match.group(1))
  903. else:
  904. price = ''
  905. # price = float(re.search(r'¥([\d\.]+)', price_str).group(1))
  906. print(f'获取到价格:{price}')
  907. print(f"ext={ext}")
  908. self.swipe_back(1) #
  909. return price, ext
  910. def restart_uiautomator_services(self, device_id):
  911. """
  912. 重启atx的uiautomator 服务
  913. :param device_id:
  914. :return:
  915. """
  916. stop_uiautomator_services = f'adb -s {device_id} shell /data/local/tmp/atx-agent server -d --stop'
  917. start_uiautomator_services = f'adb -s {device_id} shell /data/local/tmp/atx-agent server -d'
  918. subprocess.run(stop_uiautomator_services, capture_output=True, text=True, shell=True)
  919. time.sleep(self.get_sleep_time())
  920. subprocess.run(start_uiautomator_services, capture_output=True, text=True, shell=True)
  921. time.sleep(self.get_sleep_time())
  922. def connect_devices(self, device_id):
  923. """
  924. 连接设备
  925. :return:
  926. """
  927. try:
  928. self.d = u2.connect_usb(device_id)
  929. # 设置隐形等待时间
  930. # self.d.implicitly_wait(5)
  931. self.restart_uiautomator_services(device_id)
  932. print(f'[{self.program_start_time}]连接到设备:{device_id}')
  933. except Exception as e:
  934. print(f'{device_id} 连接错误: {e}')
  935. raise Exception(e)
  936. def get_ocr_res(self, img):
  937. try:
  938. image = self.remove_watermark(img)
  939. res_image = self.client.basicGeneral(image)
  940. data = res_image.get('words_result', '')
  941. print(f'百度api返回结果:{data}')
  942. return data
  943. except:
  944. return None
  945. def get_title(self):
  946. try:
  947. print('开始提取标题')
  948. time.sleep(self.get_sleep_time())
  949. title_xpath = '//*[@resource-id="com.xunmeng.pinduoduo:id/tv_title"]'
  950. if self.d.xpath(title_xpath).exists:
  951. title = self.d.xpath(title_xpath).info['contentDescription'].strip()
  952. else:
  953. return None
  954. # title = self.d.xpath('//*[@resource-id="com.xunmeng.pinduoduo:id/tv_title"]').info['contentDescription'].strip()
  955. print(f'提取到标题:{title}')
  956. return title
  957. except Exception as e:
  958. print(f'获取标题出错:{e}')
  959. return None
  960. # 从里面匹配出药品名和规格
  961. # drugs_name
  962. # specifications
  963. # match = re.search(r'([^\d]+)([\d\D]+)', title)
  964. # match = re.search(r'(\[[^\]]+\])(.+?)(\d+.*)', title)
  965. # if match:
  966. # drugs_name = match.group(1).strip() + match.group(2).strip()
  967. # specifications = match.group(3).strip()
  968. # print("药品名:", drugs_name)
  969. # print("规格:", specifications)
  970. # print('完整药名:', drugs_name + specifications)
  971. # return drugs_name, specifications
  972. # else:
  973. # print("没有匹配到预期格式")
  974. def enter_shop(self):
  975. """
  976. 进店,方便提取资质环境
  977. :return:
  978. """
  979. # self.d.xpath('//*[@text="进店"]').click()
  980. self.d.xpath('//*[@text="店铺"]').click()
  981. time.sleep(self.get_sleep_time())
  982. def data_is_exists(self, data):
  983. # 1. 验证必要字段
  984. required_keys = ['min_price', 'shop', 'scrape_date', 'platform']
  985. if not all(key in data for key in required_keys):
  986. missing = [key for key in required_keys if key not in data]
  987. print(f"缺少必要字段: {', '.join(missing)}")
  988. return None
  989. conn = None
  990. try:
  991. conn = get_mysql()
  992. with conn.cursor() as cur:
  993. query_sql = """
  994. SELECT * FROM {}
  995. WHERE min_price = %s
  996. AND store_name = %s
  997. AND scrape_date = %s
  998. AND platform_id = %s
  999. """.format(self.table_name)
  1000. cur.execute(query_sql, (
  1001. data['min_price'],
  1002. data['shop'],
  1003. data['scrape_date'],
  1004. data['platform']
  1005. ))
  1006. result = cur.fetchone()
  1007. return bool(result) # 如果存在返回True,否则False
  1008. except Exception as e:
  1009. print(f"MySQL 错误: {str(e)}")
  1010. finally:
  1011. if conn:
  1012. conn.close()
  1013. def get_instructions_data(self):
  1014. """
  1015. 确定有详情页之后之后,提取所有的详情页数据
  1016. :return:
  1017. """
  1018. for i in range(8):
  1019. if self.d.xpath('//*[@text="品牌"]').exists or self.d.xpath('//*[@text="药品通用名"]').exists:
  1020. self.d.swipe_ext("up", scale=0.1)
  1021. print('开始采集详情数据')
  1022. break
  1023. self.d.swipe_ext("up", scale=0.5)
  1024. time.sleep(self.get_sleep_time())
  1025. # 点击查看全部
  1026. if self.d.xpath('//*[@text="品牌"]').exists:
  1027. self.d.xpath('//*[@text="品牌"]').click()
  1028. else:
  1029. self.d.xpath('//*[@text="药品通用名"]').click()
  1030. time.sleep(self.get_sleep_time())
  1031. attr = dict()
  1032. # # 获取详情页信息
  1033. xpath = '//*[starts-with(@text,"商品参数")]/parent::*/parent::*/following-sibling::*/*/*/android.view.ViewGroup//android.widget.TextView'
  1034. ddd = self.d.xpath(xpath).all()
  1035. for i in range(0, len(ddd), 2):
  1036. group = ddd[i:i + 2]
  1037. attr[group[0].text] = group[1].text
  1038. # 截图获取未获取到的数据
  1039. # if not all(i in ['有效期', '生产企业', '批准文号', '药品规格', '产品规格'] for i in attr.keys()):
  1040. if not all(i in ['有效期', '生产企业', '批准文号', '药品规格'] for i in attr.keys()):
  1041. self.d.swipe_ext("up", 0.4)
  1042. time.sleep(self.get_sleep_time())
  1043. xpath = '//*[starts-with(@text,"商品参数")]/parent::*/parent::*/following-sibling::*/*/*/android.view.ViewGroup//android.widget.TextView'
  1044. ddd = self.d.xpath(xpath).all()
  1045. for i in range(0, len(ddd), 2):
  1046. group = ddd[i:i + 2]
  1047. attr[group[0].text] = group[1].text
  1048. print(f'当前说明书规格参数:{attr}')
  1049. res_data = {
  1050. # "有效期": attr.get('有效期',''),
  1051. # "生产单位": attr['生产企业'],
  1052. # "批准文号": attr['批准文号'],
  1053. # "产品规格": attr.get('药品规格') if attr.get('药品规格', '') else attr.get('药品规格')
  1054. "有效期": attr.get('有效期', ''),
  1055. "生产单位": attr.get('生产企业', ''),
  1056. "批准文号": attr.get('批准文号', ''),
  1057. "产品规格": attr.get('药品规格', '')
  1058. }
  1059. print(f'当前规格参数字典数据:{res_data}')
  1060. return res_data
  1061. def has_instructions(self):
  1062. """
  1063. 是否有详情页
  1064. :return:如果有详情页返回True,否则返回False
  1065. """
  1066. # 没有说明书的无法采集具体数据
  1067. max_attempts = 12 # 最大尝试次数
  1068. attempt = 0 # 当前尝试次数
  1069. while attempt < max_attempts:
  1070. time.sleep(0.5)
  1071. xpath = '//*[@text="商品详情"]'
  1072. is_has_instructions = self.d.xpath(xpath).exists
  1073. if is_has_instructions:
  1074. return True # 如果找到“商品详情”,则返回True
  1075. self.d.swipe_ext("up", 0.3)
  1076. attempt += 1
  1077. return False # 如果尝试次数达到最大次数,则返回False
  1078. def distinct_target(self):
  1079. result = False
  1080. is_position = self.d.xpath('//*[@content-desc="拍照搜索"]').exists
  1081. is_position2 = self.d.xpath('//*[@text="年货节大促"]').exists
  1082. is_position3 = self.d.xpath('//*[@text="筛选"]').exists
  1083. is_position4 = self.d.xpath('//*[@text="回头客常拼"]').exists
  1084. list_page_xpath = '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[2]/android.view.ViewGroup[1]/android.widget.LinearLayout[1]//android.support.v7.widget.RecyclerView[1]'
  1085. is_position_new = self.d.xpath(list_page_xpath).exists
  1086. print(f'is_position_new={is_position_new}')
  1087. if is_position or is_position2 or is_position3 or is_position4 or is_position_new:
  1088. result = True
  1089. return result
  1090. def enter_target_page(self):
  1091. self.d.xpath(
  1092. '//*[@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]').click()
  1093. time.sleep(self.get_sleep_time())
  1094. self.d(className='android.widget.EditText').click()
  1095. time.sleep(self.get_sleep_time())
  1096. self.d.send_keys(self.search_key, clear=True)
  1097. time.sleep(self.get_sleep_time())
  1098. self.d.xpath('//*[@text="搜索"]').click()
  1099. time.sleep(self.get_sleep_time())
  1100. if self.sort and self.sort_key == 0:
  1101. self.li_or_lo(self.sort)
  1102. # progress = self.wr_re("读", self.device_id)
  1103. progress = None
  1104. if not progress and self.page > 0:
  1105. self.scroll_to_target_page(self.page)
  1106. def get_clipboard(self):
  1107. self.loggerPdd.info(f"Clipboard content:{self.d.clipboard}") # 打印调试信息
  1108. clipboard_content = self.d.clipboard
  1109. if clipboard_content is None:
  1110. return ''
  1111. return clipboard_content.strip()
  1112. def get_product_link(self):
  1113. product_link = ''
  1114. print('开始获取商品链接')
  1115. content_frame = self.d.xpath('//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]').exists
  1116. print(content_frame)
  1117. relative_layout = self.d.xpath(
  1118. '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]').exists
  1119. print(relative_layout)
  1120. relative_layout2 = self.d.xpath(
  1121. '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.RelativeLayout[1]').exists
  1122. print(relative_layout2)
  1123. Frame_Layout = self.d.xpath(
  1124. '//*[@resource-id="android:id/content"]/android.widget.FrameLayout[1]/android.widget.RelativeLayout[1]/android.widget.RelativeLayout[1]/android.widget.FrameLayout[2]').exists
  1125. print(Frame_Layout)
  1126. ImageView = self.d.xpath(
  1127. '//*[@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
  1128. print(ImageView)
  1129. ImageView2 = self.d.xpath(
  1130. '//*[@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
  1131. print(ImageView2)
  1132. # 多种可能的“分享”按钮
  1133. dots_xpaths = [
  1134. # '//*[@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]',
  1135. '//*[@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]',
  1136. # '//*[@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]',
  1137. # '//*[@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]',
  1138. ]
  1139. max_retry = 5 # 最多尝试次数
  1140. for idx in range(1, max_retry + 1):
  1141. if product_link: # 已经拿到则退出
  1142. break
  1143. for xp in dots_xpaths:
  1144. if self.d.xpath(xp).exists:
  1145. # print(f'{idx}-进入分享点点点')
  1146. self.loggerPdd.info(f'{idx}-进入分享点点点')
  1147. self.d.xpath(xp).click()
  1148. time.sleep(1)
  1149. self.loggerPdd.info('开始滑动')
  1150. self.slide_link()
  1151. time.sleep(0.2)
  1152. self.d.xpath('//*[@text="复制链接"]').click_exists()
  1153. time.sleep(1)
  1154. product_link = self.get_clipboard()
  1155. time.sleep(0.5)
  1156. self.loggerPdd.info(f'{idx}-商品链接:{product_link}')
  1157. break # 找到并执行后跳出内层循环
  1158. if not product_link and idx < max_retry:
  1159. time.sleep(0.5) # 最后一次不需要再等待
  1160. # time.sleep(100000)
  1161. return product_link
  1162. def integrate_data_v2(self):
  1163. """
  1164. 基于入口配置统一校验标题、品牌和品规,替代内部大量硬编码分支。
  1165. """
  1166. min_price, ext = self.drug_price_ex()
  1167. title_info = self.get_title()
  1168. if not title_info:
  1169. print('标题获取为空')
  1170. self.swipe_back(1)
  1171. return
  1172. if not self.is_link_useful(title_info):
  1173. self.swipe_back(1)
  1174. self.unrelated_data += 1
  1175. return
  1176. if not min_price:
  1177. min_price = self.drug_price()
  1178. if not min_price:
  1179. print('提取价格出错,回退到列表页')
  1180. self.swipe_back(1)
  1181. self.unrelated_data += 1
  1182. return
  1183. product_link = self.get_product_link()
  1184. time.sleep(2)
  1185. if self.direct_shop_lookup:
  1186. shop = self.get_shop_name()
  1187. else:
  1188. for _ in range(15):
  1189. if self.d(textStartsWith="进店").exists:
  1190. print('开始获取店铺名')
  1191. break
  1192. self.d.swipe_ext("up", scale=0.3)
  1193. time.sleep(self.get_sleep_time())
  1194. if self.d(textStartsWith="进店").exists:
  1195. print('可以开始获取店铺名')
  1196. shop = self.get_shop_name()
  1197. if not shop:
  1198. print('当前店铺名称为空')
  1199. self.swipe_back(1)
  1200. self.unrelated_data += 1
  1201. return
  1202. scrape_date = self.get_current_date()
  1203. dup_data = {
  1204. 'min_price': min_price,
  1205. 'shop': shop,
  1206. 'scrape_date': scrape_date,
  1207. 'platform': '3'
  1208. }
  1209. if self.data_is_exists(dup_data):
  1210. print('存在相同数据不入库')
  1211. self.back_to_list_page()
  1212. return
  1213. is_has_instructions = self.has_instructions()
  1214. self.loggerPdd.info(f'是否有说明书:{is_has_instructions}')
  1215. manufacture_date = ''
  1216. credit_code = ext
  1217. if is_has_instructions:
  1218. try:
  1219. instructions_info = self.get_instructions_data()
  1220. expiry_date = instructions_info['有效期'].strip('。')
  1221. manufacturer = instructions_info['生产单位'].strip('。')
  1222. approval_number = instructions_info['批准文号'].strip('。')
  1223. specifications = instructions_info['产品规格'].strip('。')
  1224. except Exception as e:
  1225. print(f'获取详情页规格参数出错:{e}')
  1226. self.swipe_back(2)
  1227. return
  1228. else:
  1229. expiry_date = ''
  1230. manufacturer = ''
  1231. approval_number = ''
  1232. specifications = ''
  1233. if not self.is_link_useful(title_info, specifications):
  1234. self.swipe_back(1)
  1235. self.unrelated_data += 1
  1236. return
  1237. self.unrelated_data = 0
  1238. if extract_box_number(credit_code):
  1239. one_box_price = min_price / extract_box_number(credit_code)
  1240. else:
  1241. print("单瓶药品价格没处理成功")
  1242. one_box_price = 0
  1243. save_data = {
  1244. 'enterprise_id': self.enterprise_id,
  1245. 'platform_id': self.platform,
  1246. 'platform_item_id': '',
  1247. 'province_id': 0,
  1248. 'city_id': 0,
  1249. 'province_name': '',
  1250. 'city_name': '',
  1251. 'area_info': "",
  1252. 'product_name': title_info,
  1253. 'product_specs': specifications,
  1254. 'one_box_price': one_box_price,
  1255. 'manufacture_date': manufacture_date,
  1256. 'expiry_date': expiry_date,
  1257. 'manufacturer': manufacturer,
  1258. 'approval_number': approval_number,
  1259. 'is_sold_out': 0,
  1260. 'online_posting_count': 1,
  1261. 'continuous_listing_count': 1,
  1262. 'link_url': product_link,
  1263. 'store_name': shop,
  1264. 'store_url': '',
  1265. 'shipment_province_id': 0,
  1266. 'shipment_province_name': "",
  1267. 'shipment_city_id': 0,
  1268. 'shipment_city_name': "",
  1269. 'company_name': "",
  1270. 'qualification_number': "",
  1271. 'scrape_date': scrape_date,
  1272. 'min_price': min_price,
  1273. 'number': 0,
  1274. 'sales': "",
  1275. 'inventory': "",
  1276. 'snapshot_url': "",
  1277. 'insert_time': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
  1278. }
  1279. self.save_to_database(save_data)
  1280. def main(self, device_id, search_key_length, keyword_idx):
  1281. completed_normally = False
  1282. stop_by_max_count = False
  1283. spider_no = 0
  1284. current_page = self.page
  1285. self.connect_devices(device_id)
  1286. time.sleep(self.get_sleep_time())
  1287. if keyword_idx == 0:
  1288. print("搜索前,先重启APP")
  1289. self.restart_app()
  1290. # 搜索关键字
  1291. self.enter_target_page()
  1292. else:
  1293. print("清空前面的文字,再输入关键词")
  1294. self.d.send_keys(self.search_key, clear=True)
  1295. time.sleep(1)
  1296. print("点击搜索")
  1297. self.d.xpath('//*[@text="搜索"]').click()
  1298. time.sleep(1)
  1299. # 上报状态
  1300. report_api(self.task_id, self.page, 2,finish_status=0)
  1301. for idx in range(300):
  1302. print(f'第{current_page}页')
  1303. # self.wr_re("写", self.device_id, self.sort, current_page)
  1304. if spider_no > 30:
  1305. time.sleep(300)
  1306. spider_no = 0
  1307. if self.unrelated_data > 30:
  1308. print(f'[{self.program_start_time}]----{self.search_key}----连续超过30个不达标的数据则停止采集')
  1309. print(
  1310. f"[程序启动时间:{self.program_start_time}-----程序结束时间:{self.app_current_time()}]----搜索关键词:{self.search_key}----点击了{self.click_counts}个商品")
  1311. self.swipe_down()
  1312. time.sleep(self.get_sleep_time()) # 下滑后等待页面稳定
  1313. click_success = self.click_target_product_by_search_key(fuzzy_match=False)
  1314. if not click_success:
  1315. self.finish_task_abnormally(
  1316. current_page,
  1317. f"连续超过30个不达标的数据后,关键词「{self.search_key}」商品点击失败",
  1318. finish_status=1
  1319. )
  1320. return
  1321. print("点击搜索框")
  1322. self.d(className='android.widget.EditText').click()
  1323. time.sleep(self.get_sleep_time())
  1324. if keyword_idx == search_key_length - 1:
  1325. print("程序最后一个品规采集完毕,返回主屏幕")
  1326. completed_normally = self.finish_task_normally(
  1327. current_page,
  1328. '连续超过30个不达标的数据,结束采集'
  1329. )
  1330. else:
  1331. completed_normally = True
  1332. break
  1333. if self.is_max_count_reached():
  1334. completed_normally = self.finish_task_with_max_count(current_page)
  1335. # 向下滑
  1336. self.swipe_down()
  1337. time.sleep(self.get_sleep_time())
  1338. # 点击搜索框
  1339. click_success = self.click_target_product_by_search_key(fuzzy_match=False)
  1340. if not click_success:
  1341. print(f"关键词「{self.search_key}」商品点击失败")
  1342. return
  1343. print("点击搜索框")
  1344. self.d(className='android.widget.EditText').click()
  1345. time.sleep(self.get_sleep_time())
  1346. break
  1347. # 售罄次数大于4基本就是号废了但是如果下次点击不会出现这种情况就要重置为0
  1348. if self.sold_out_counts > 4:
  1349. self.finish_task_abnormally(
  1350. current_page,
  1351. "====商品已售罄4次,结束采集(号不能用)",
  1352. finish_status=1
  1353. )
  1354. print(
  1355. f"[程序启动时间:{self.program_start_time}-----程序结束时间:{self.app_current_time()}]----搜索关键词:{self.search_key}----点击了{self.click_counts}个商品")
  1356. break
  1357. drug_lis = self.get_drug_lis(idx)
  1358. print('数量', len(drug_lis))
  1359. for idd, drug_one in enumerate(drug_lis):
  1360. print(idd + 1, drug_one.info)
  1361. time.sleep(self.get_sleep_time())
  1362. top = drug_one.info['bounds']['top']
  1363. bottom = drug_one.info['bounds']['bottom']
  1364. if bottom <= 1524 and top >= 258:
  1365. drug_one.click()
  1366. self.click_counts += 1
  1367. time.sleep(self.get_sleep_time())
  1368. # 先判断是否售罄次数是否大于4
  1369. if self.sold_out_counts >= 4:
  1370. print(
  1371. f"[程序启动时间:{self.program_start_time}-----程序结束时间:{self.app_current_time()}]----搜索关键词:{self.search_key}----点击了{self.click_counts}个商品")
  1372. self.finish_task_abnormally(
  1373. current_page,
  1374. "====这是在第一页有两个,商品已售罄4次,结束采集(号不能用)====",
  1375. finish_status=1
  1376. )
  1377. time.sleep(self.get_sleep_time())
  1378. self.d.press('home')
  1379. return
  1380. if self.d.xpath('//*[contains(@text, "商品已售罄")]').wait(timeout=5):
  1381. print("======商品已售罄======")
  1382. self.sold_out_counts += 1
  1383. if self.back_to_list_page():
  1384. continue
  1385. # 采集药品信息
  1386. try:
  1387. # 重置商品售罄次数
  1388. self.sold_out_counts = 0
  1389. self.integrate_data_v2()
  1390. # 检测下是否回退到列表页
  1391. if self.back_to_list_page():
  1392. print('回退到列表页', True)
  1393. else:
  1394. print(f'[{self.app_current_time()}] 回退到列表页失败')
  1395. print(
  1396. f"[程序启动时间:{self.program_start_time}-----结束时间:{self.app_current_time()}]----搜索关键词:{self.search_key}----点击了{self.click_counts}个商品")
  1397. self.finish_task_abnormally(current_page, "回退到列表页失败,结束采集")
  1398. return
  1399. time.sleep(self.get_sleep_time())
  1400. spider_no += 1
  1401. if self.is_max_count_reached():
  1402. completed_normally = self.finish_task_with_max_count(current_page)
  1403. stop_by_max_count = True
  1404. break
  1405. except Exception as e:
  1406. self.loggerPdd.error(f'采集药品详情数据出错:{e}')
  1407. if not self.back_to_list_page():
  1408. self.finish_task_abnormally(current_page, '采集药品详情数据出错且无法回到列表页,结束采集')
  1409. return
  1410. else:
  1411. continue
  1412. if stop_by_max_count:
  1413. break
  1414. if self.end_page is not None and current_page >= self.end_page:
  1415. completed_normally = self.finish_task_normally(
  1416. current_page,
  1417. f"已采集到结束页 {self.end_page},结束任务"
  1418. )
  1419. break
  1420. if self.d(textStartsWith="抱歉,没有更多商品啦~").exists:
  1421. completed_normally = self.finish_task_normally(current_page, '已经到达列表页最底部')
  1422. break
  1423. print('开始滑入下一页')
  1424. end_y = 300
  1425. self.d.swipe(200, 1400, 200, end_y, 0.4)
  1426. time.sleep(self.get_sleep_time())
  1427. if completed_normally:
  1428. self.clear_progress_file()
  1429. elif not self.finish_reported:
  1430. self.finish_task_abnormally(current_page, "采集流程异常结束")
  1431. return completed_normally
  1432. # pdd
  1433. def main():
  1434. logging.info(f"PDD 调度器启动,轮询间隔 {SCHEDULER_INTERVAL_SECONDS} 秒")
  1435. dispatch_pending_tasks()
  1436. schedule_dispatch(SCHEDULER_INTERVAL_SECONDS)
  1437. scheduler_stop_event.wait()
  1438. if __name__ == '__main__':
  1439. main()