3
0

ysb_snapshot_crawl.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906
  1. import base64
  2. import hashlib
  3. import json
  4. import math
  5. import random
  6. import re
  7. import signal
  8. import socket
  9. import sys
  10. import time
  11. import zlib
  12. from pathlib import Path
  13. import requests
  14. import secrets
  15. import string
  16. from Crypto.Cipher import AES
  17. from commons.conn_mysql import MySQLPoolOn2
  18. from DrissionPage import ChromiumPage, ChromiumOptions
  19. from commons.Logger import logger
  20. from oss_upload.oss_upload import AliyunOSSUploader
  21. from pipelines.drug_pipelines import DrugPipeline
  22. from datetime import datetime, timedelta
  23. from area_info.city_name_to_id import get_city
  24. import platform
  25. CAPTCHA_TOKEN = "zPzmt1mG1ouCU6GTzsZN2Lmm8pdZypapPcLJTBRETco"
  26. CAPTCHA_API_URL = "http://api.jfbym.com/api/YmServer/customApi"
  27. SLIDER_OFFSET_FIX = 10
  28. DETAIL_GET_TIMEOUT = 15
  29. DETAIL_URL_WAIT = 10
  30. DETAIL_DOM_WAIT = 8
  31. DETAIL_NAV_RETRIES = 3
  32. DETAIL_APPROVAL_XPATH = (
  33. 'xpath://div[@class="drug-info"]//span[contains(text(),"批准文号")]'
  34. )
  35. chrome_path = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
  36. PROJECT_ROOT = Path(__file__).resolve().parents[2]
  37. YSB_SPIDER_DIR = PROJECT_ROOT / "spiders" / "yaoshibang"
  38. headers = {
  39. "Accept": "*/*",
  40. "Accept-Language": "zh-CN,zh;q=0.9",
  41. "Connection": "keep-alive",
  42. "Content-Type": "application/json",
  43. "Origin": "https://dian.ysbang.cn",
  44. "Referer": "https://dian.ysbang.cn/",
  45. "Sec-Fetch-Dest": "empty",
  46. "Sec-Fetch-Mode": "cors",
  47. "Sec-Fetch-Site": "same-origin",
  48. "User-Agent": (
  49. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
  50. "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
  51. ),
  52. "sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
  53. "sec-ch-ua-mobile": "?0",
  54. "sec-ch-ua-platform": '"Windows"',
  55. }
  56. def pkcs7_unpad(data):
  57. if not data:
  58. raise ValueError("Empty data for PKCS7 unpad")
  59. pad_len = data[-1]
  60. if pad_len < 1 or pad_len > 16:
  61. raise ValueError("Invalid PKCS7 padding length")
  62. if data[-pad_len:] != bytes([pad_len]) * pad_len:
  63. raise ValueError("Invalid PKCS7 padding bytes")
  64. return data[:-pad_len]
  65. def derive_ysb_key():
  66. base = "BhCLxFfFhd12K4qRGPfy"
  67. md5_hex = hashlib.md5(base.encode("utf-8")).hexdigest()
  68. return md5_hex[:16].upper().encode("utf-8")
  69. def decrypt_ysb_payload(cipher_text_b64):
  70. """解密药师帮列表接口 data.o 字段,返回 JSON 对象。"""
  71. key = derive_ysb_key()
  72. cipher_bytes = base64.b64decode(cipher_text_b64)
  73. cipher = AES.new(key, AES.MODE_ECB)
  74. decrypted = cipher.decrypt(cipher_bytes)
  75. unpadded = pkcs7_unpad(decrypted)
  76. json_bytes = zlib.decompress(unpadded, zlib.MAX_WBITS | 16)
  77. return json.loads(json_bytes.decode("utf-8"))
  78. class YaoShiBangSnapshot:
  79. def __init__(self, drug_dict=None):
  80. self.driver = None
  81. self.db = MySQLPoolOn2()
  82. self.ip = None
  83. self.login_username = None
  84. self.login_password = None
  85. self.platform = 5
  86. self.page = 0
  87. self.pipeline = DrugPipeline("ysb")
  88. self.task_dict = drug_dict or {}
  89. self.ossuploader = AliyunOSSUploader()
  90. self.start_page = 1
  91. self.end_page = 1
  92. self.account_name = None
  93. self.phone = None
  94. self.password = None
  95. self._register_signal_handler()
  96. if self.task_dict:
  97. self.get_product_data()
  98. self.success = True
  99. self.is_no_prodcut = 0
  100. self.is_product_count = 0
  101. self.token = ""
  102. self._state_value = ""
  103. self.start_date = (datetime.now() - timedelta(minutes=500)).strftime("%Y-%m-%d %H:%M")
  104. def get_product_data(self):
  105. self.task_id = self.task_dict["id"]
  106. self.company_id = self.task_dict["company_id"]
  107. self.product = self.task_dict["product_name"]
  108. self.product_desc = self.task_dict.get("product_specs", "")
  109. self.brand = self.task_dict.get("product_brand", "")
  110. self.product_keyword = self.task_dict.get("product_keyword", "")
  111. self.collect_task_id = self.task_dict.get("collect_task_id", "")
  112. self.sampling_cycle = self.task_dict.get("sampling_cycle", "")
  113. self.sampling_start_time = self.task_dict.get("sampling_start_time", "")
  114. self.sampling_end_time = self.task_dict.get("sampling_end_time", "")
  115. self.collect_equipment_id = self.task_dict.get("collect_equipment_id", "")
  116. self.account_id = self.task_dict.get("collect_equipment_account_id", "")
  117. self.collect_region_id = self.task_dict.get("collect_region_id", "")
  118. self.collect_round = self.task_dict.get("collect_round", 1)
  119. self.start_page = self._parse_page(self.task_dict.get("start_page"), 1)
  120. self.end_page = max(
  121. self.start_page,
  122. self._parse_page(self.task_dict.get("end_page"), self.start_page),
  123. )
  124. def _report_exception(self, exception_type, remark=""):
  125. """异常回告,上报 exception_type (1-6)"""
  126. scheduler = self.task_dict.get("scheduler")
  127. if scheduler:
  128. data = {
  129. "task_id": self.task_id,
  130. "platform": self.task_dict.get("_platform", str(self.platform)),
  131. "username": self.task_dict.get("_device_id", ""),
  132. "current_page": self.page,
  133. "is_finished": 0,
  134. "exception_type": exception_type,
  135. "remark": remark,
  136. }
  137. if self.page:
  138. data["total_pages"] = self.page
  139. if self.pipeline.crawl_count:
  140. data["crawled_count"] = self.pipeline.crawl_count
  141. scheduler.post_report(data)
  142. def _report_page_progress(self, page, crawled_count, is_finished=0, total_pages=0):
  143. """逐页回告进度,返回 True=code=error 需停止"""
  144. scheduler = self.task_dict.get("scheduler")
  145. if scheduler:
  146. data = {
  147. "task_id": self.task_id,
  148. "platform": self.task_dict.get("_platform", str(self.platform)),
  149. "username": self.task_dict.get("_device_id", ""),
  150. "current_page": page,
  151. "crawled_count": crawled_count,
  152. "is_finished": is_finished,
  153. }
  154. if total_pages:
  155. data["total_pages"] = total_pages
  156. scheduler.post_report(data)
  157. return scheduler.end
  158. return False
  159. @staticmethod
  160. def _parse_page(value, default=1):
  161. try:
  162. page = int(value)
  163. return page if page >= 1 else default
  164. except (TypeError, ValueError):
  165. return default
  166. def _register_signal_handler(self):
  167. def handler(signum, frame):
  168. logger.info("收到退出信号,正在关闭浏览器...")
  169. self._quit_browser()
  170. sys.exit(0)
  171. signal.signal(signal.SIGINT, handler)
  172. if hasattr(signal, "SIGTERM"):
  173. signal.signal(signal.SIGTERM, handler)
  174. def _quit_browser(self):
  175. if self.driver:
  176. try:
  177. self.driver.quit()
  178. except Exception:
  179. pass
  180. self.driver = None
  181. @staticmethod
  182. def _get_free_port():
  183. """获取一个当前可用的本地端口,供 Chrome 调试使用。"""
  184. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  185. s.bind(("127.0.0.1", 0))
  186. return s.getsockname()[1]
  187. def _resolve_browser_profile_dir(self):
  188. """浏览器数据目录: <项目根>/spiders/yaoshibang/<账号>"""
  189. profile_dir = YSB_SPIDER_DIR / self.account_name
  190. # 仅兼容历史误路径,新建不再使用 chrome_profile
  191. legacy_nested = YSB_SPIDER_DIR / "spiders" / "yaoshibang" / self.account_name
  192. legacy_chrome_profile = YSB_SPIDER_DIR / "chrome_profile" / self.account_name
  193. for candidate in (profile_dir, legacy_nested, legacy_chrome_profile):
  194. if (candidate / "Default").is_dir() or (candidate / "Local State").is_file():
  195. logger.info("使用已有浏览器配置目录: %s", candidate)
  196. return candidate
  197. profile_dir.mkdir(parents=True, exist_ok=True)
  198. logger.info("新建浏览器配置目录: %s", profile_dir)
  199. return profile_dir
  200. def get_account(self):
  201. # 优先用入口传入的账号密码,避免重复查 DB
  202. phone = self.task_dict.get("account_phone")
  203. password = self.task_dict.get("account_password")
  204. name = self.task_dict.get("account_name")
  205. if phone and password:
  206. self.account_name = name or phone
  207. self.phone = phone
  208. self.password = password
  209. logger.info("使用入口传入的账号: name=%s, phone=%s", self.account_name, phone)
  210. return {"name": self.account_name, "phone": phone, "password": password}
  211. # 兜底:查 DB
  212. sql_account = f""" select `id`,`name`,`ip`,`phone`,`password`,`cookie_timestamp`,`cookie_str` from `accounts_platform` where `platform`=5 and `status`=1 and `equipment_id`=3 order by `cookie_timestamp` asc limit 1 """
  213. account_list = self.db.select_data(sql_account)
  214. if not account_list:
  215. return {}
  216. account_dict = account_list[0]
  217. self.account_name = account_dict["name"]
  218. self.phone = account_dict["phone"]
  219. self.password = account_dict["password"]
  220. return account_dict
  221. def init_browser(self):
  222. co = ChromiumOptions()
  223. # ---------- 1. 根据系统选择 Chrome 路径 ----------
  224. system = platform.system()
  225. if system == "Windows":
  226. chrome_path = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
  227. # 如果装在非默认路径,可再追加备选
  228. elif system == "Linux":
  229. chrome_path = "/usr/bin/google-chrome" # 或 "/usr/bin/chromium-browser"
  230. else: # macOS 或其他
  231. chrome_path = None # 让 DrissionPage 自动查找
  232. if chrome_path:
  233. co.set_browser_path(chrome_path)
  234. # ---------- 2. 统一使用 set_argument 设置(避免 set_headless 报错) ----------
  235. co.set_argument('--headless') # 旧版无头(更兼容)
  236. co.set_argument('--window-size=1920,1080')
  237. co.set_argument('--no-sandbox') # Linux 必需,Windows 下被忽略,无害
  238. co.set_argument('--disable-dev-shm-usage') # Linux 解决 /dev/shm 问题,Windows 无害
  239. co.set_argument('--disable-gpu') # 无头模式下可减少 GPU 问题,兼容所有系统
  240. co.set_argument('--disable-blink-features=AutomationControlled')
  241. co.set_argument('--start-maximized')
  242. co.set_argument('--no-first-run')
  243. co.set_argument('--no-default-browser-check')
  244. # ---------- 3. 用户数据目录(跨平台) ----------
  245. # 例如放在项目目录下的 profiles/ 中,按账号区分
  246. profile_dir = Path("./browser_profiles") / self.account_name
  247. profile_dir.mkdir(parents=True, exist_ok=True)
  248. co.set_user_data_path(str(profile_dir))
  249. co.set_argument("--remote-debugging-port=0")
  250. co.set_argument("--remote-debugging-address=127.0.0.1")
  251. self.driver = ChromiumPage(co)
  252. def _solve_slider_captcha(self, yidun=None):
  253. """检测并处理易盾滑块验证码,成功返回 True。"""
  254. self.driver.wait.doc_loaded()
  255. time.sleep(2)
  256. captcha_text = self.driver.ele("xpath://*[contains(text(),'安全验证') or contains(text(),'验证码')]", timeout=1)
  257. if not captcha_text:
  258. captcha_text = self.driver.ele("xpath://div[@class='yidun_modal']", timeout=2)
  259. if not captcha_text:
  260. return True
  261. logger.warning("检测到验证码: %s", captcha_text.inner_text()[:50])
  262. logger.info("检测到滑块验证码,开始处理")
  263. jpg_bytes = yidun.get_screenshot(as_bytes="jpg")
  264. distance = self._call_captcha_api(jpg_bytes)
  265. if distance is None:
  266. logger.error("验证码识别失败")
  267. self._report_exception(3, "滑块验证码识别失败")
  268. return False
  269. logger.info("滑块距离: %s", distance)
  270. slider = self.driver.ele(
  271. "xpath://div[contains(@class,'yidun_slider--hover')]", timeout=5
  272. )
  273. if not slider:
  274. logger.error("未找到滑块元素")
  275. self._report_exception(3, "未找到滑块元素")
  276. return False
  277. try:
  278. drag_distance = float(distance) + SLIDER_OFFSET_FIX
  279. except (TypeError, ValueError):
  280. logger.error("滑块距离非数字: %r", distance)
  281. return False
  282. if not math.isfinite(drag_distance) or drag_distance <= 0:
  283. logger.error("滑块距离无效: %s", drag_distance)
  284. return False
  285. self._simulate_slider_drag(slider, drag_distance - 5)
  286. time.sleep(3)
  287. return True
  288. def _call_captcha_api(self, image_bytes):
  289. """调用云码平台识别滑块距离,失败返回 None。"""
  290. try:
  291. b64 = base64.b64encode(image_bytes).decode()
  292. resp = requests.post(
  293. CAPTCHA_API_URL,
  294. json={"token": CAPTCHA_TOKEN, "type": "22222", "image": b64},
  295. headers={"Content-Type": "application/json"},
  296. timeout=15,
  297. ).json()
  298. logger.info("验证码 API 返回: %s", resp)
  299. if not isinstance(resp, dict):
  300. return None
  301. data = resp.get("data")
  302. if isinstance(data, dict):
  303. dist = data.get("data")
  304. else:
  305. dist = data
  306. if dist is None:
  307. logger.error("验证码 API 未返回距离字段: %s", resp)
  308. return None
  309. try:
  310. d = float(dist)
  311. except (TypeError, ValueError):
  312. logger.error("验证码距离无法解析为数字: %r", dist)
  313. return None
  314. if not math.isfinite(d):
  315. logger.error("验证码距离非有限数值: %r", dist)
  316. return None
  317. return d
  318. except Exception as e:
  319. logger.exception("验证码 API 调用失败: %s", e)
  320. return None
  321. @staticmethod
  322. def _generate_human_track(distance):
  323. try:
  324. distance = float(distance)
  325. except (TypeError, ValueError):
  326. return []
  327. if distance <= 0 or not math.isfinite(distance):
  328. return []
  329. tracks = []
  330. current = 0
  331. mid = distance * 0.7
  332. t = 0.2
  333. v = 0
  334. move_points = []
  335. while current < mid:
  336. a = random.uniform(2, 4)
  337. v0 = v
  338. v = v0 + a * t
  339. move = v0 * t + 0.5 * a * t * t
  340. current += move
  341. move_points.append(move)
  342. while current < distance:
  343. a = -random.uniform(0.5, 1.5)
  344. v0 = v
  345. v = v0 + a * t
  346. if v < 0.5:
  347. v = 0.5
  348. move = v0 * t + 0.5 * a * t * t
  349. current += move
  350. move_points.append(move)
  351. total_points = len(move_points)
  352. for i, move in enumerate(move_points):
  353. y_offset = random.randint(-2, 2) if i % random.randint(2, 4) == 0 else 0
  354. if i < total_points * 0.3:
  355. duration = random.uniform(0.01, 0.03)
  356. elif i > total_points * 0.7:
  357. duration = random.uniform(0.03, 0.08)
  358. else:
  359. duration = random.uniform(0.02, 0.05)
  360. if random.random() < 0.05:
  361. duration += random.uniform(0.05, 0.1)
  362. tracks.append((move, y_offset, duration))
  363. if random.random() < 0.7:
  364. tracks.append((-random.randint(1, 3), 0, 0.05))
  365. return tracks
  366. def _simulate_slider_drag(self, slider_element, target_distance):
  367. if target_distance <= 0:
  368. logger.warning("滑块目标距离无效: %s", target_distance)
  369. return
  370. self.driver.actions.move_to(slider_element).hold()
  371. for offset_x, offset_y, duration in self._generate_human_track(target_distance):
  372. self.driver.actions.move(offset_x, offset_y, duration=duration / 1000)
  373. self.driver.actions.release()
  374. def _is_logged_in(self):
  375. # 与当前账号店铺展示文案一致;换店后需同步修改或改为配置项
  376. title = self.driver.ele(
  377. "xpath=//span[@class='logout']",
  378. timeout=5,
  379. )
  380. return bool(title)
  381. def _current_url(self):
  382. try:
  383. return self.driver.url or ""
  384. except Exception:
  385. return ""
  386. def _goto_detail_page(self, item_id, detail_url):
  387. """get 后 refresh 一次,让 SPA 按当前 URL 重新渲染详情。"""
  388. if not self._solve_slider_captcha():
  389. logger.error("验证码处理失败,跳过当前商品")
  390. return False
  391. for attempt in range(1, DETAIL_NAV_RETRIES + 1):
  392. try:
  393. self.driver.get(detail_url, timeout=5)
  394. time.sleep(1.5)
  395. eles = self.driver.eles("xpath=//div[@class='y-dialog']//button[contains(text(),'确认')]", timeout=3)
  396. if len(eles) == 2:
  397. eles[1].click()
  398. time.sleep(1)
  399. self.driver.refresh()
  400. time.sleep(1.5)
  401. ele = self.driver.ele("xpath=//div[@class='drug-pic-viewer']")
  402. if not ele:
  403. continue
  404. else:
  405. return True
  406. except Exception as e:
  407. logger.warning(
  408. "跳转详情异常 item_id=%s attempt=%s: %s",
  409. item_id, attempt, e,
  410. )
  411. time.sleep(random.uniform(0.8, 1.5))
  412. return False
  413. def login(self):
  414. logger.info("开始登录药师帮")
  415. self.driver.get("https://dian.ysbang.cn/#/login", timeout=15)
  416. self.driver.wait.doc_loaded(timeout=10)
  417. time.sleep(2)
  418. input_name = self.driver.ele("xpath://input[@name='userAccount']", timeout=5)
  419. if not input_name:
  420. logger.error("未找到账号输入框")
  421. self._report_exception(4, "登录页未找到账号输入框")
  422. return False
  423. input_name.input(self.phone)
  424. time.sleep(random.uniform(1.5, 2.5))
  425. input_pass = self.driver.ele("xpath://input[@name='password']", timeout=5)
  426. if not input_pass:
  427. logger.error("未找到密码输入框")
  428. self._report_exception(4, "登录页未找到密码输入框")
  429. return False
  430. input_pass.input(self.password)
  431. time.sleep(random.uniform(1.5, 2.5))
  432. login_btn = self.driver.ele("xpath://button[text()='登录']", timeout=5)
  433. if not login_btn:
  434. logger.error("未找到登录按钮")
  435. self._report_exception(4, "登录页未找到登录按钮")
  436. return False
  437. login_btn.click()
  438. time.sleep(3)
  439. for i in range(3):
  440. self._solve_slider_captcha()
  441. time.sleep(3)
  442. if self._is_logged_in():
  443. logger.info("登录成功")
  444. return True
  445. logger.error("登录后未检测到目标店铺名,登录可能失败")
  446. return False
  447. def save_cookies(self, cookies_list):
  448. cookies_dict = {c['name']: c['value'] for c in cookies_list}
  449. timestamp = int(time.time())
  450. next_update_time = timestamp + random.randint(3600, 7200)
  451. update_sql = f""" UPDATE `accounts_platform` SET `cookie_timestamp` = %s, `cookie_str`= %s,`cookie_update_time` = %s, `status`= %s WHERE `name` = %s; """
  452. self.db.execute(update_sql,
  453. (timestamp, json.dumps(cookies_dict), next_update_time, 1, self.account_name))
  454. logger.info("cookie已保存成功")
  455. def _take_snapshot(self, upload_key):
  456. """在当前页面截图并上传,不再重复跳转。"""
  457. time.sleep(1)
  458. self._dismiss_popup_before_screenshot()
  459. try:
  460. jpg_bytes = self.driver.get_screenshot(as_bytes="jpg", full_page=True)
  461. if not jpg_bytes:
  462. logger.warning("截图为空 upload_key=%s", upload_key)
  463. return ""
  464. img_url = self.ossuploader.upload_from_bytes(jpg_bytes, str(upload_key))
  465. except Exception:
  466. logger.exception("截图或 OSS 上传失败 upload_key=%s", upload_key)
  467. return ""
  468. if not img_url:
  469. logger.warning("OSS 未返回有效地址 upload_key=%s", upload_key)
  470. return ""
  471. logger.info("截图上传完成 upload_key=%s url=%s", upload_key, img_url)
  472. time.sleep(random.uniform(1, 2))
  473. return img_url
  474. def gen_pair(self, ex1_len=9, o_raw_len=16):
  475. alphabet = string.ascii_lowercase + string.digits
  476. ex1 = "".join(secrets.choice(alphabet) for _ in range(ex1_len))
  477. o = base64.b64encode(secrets.token_bytes(o_raw_len)).decode("ascii")
  478. return {"ex1": ex1, "o": o}
  479. def build_base_payload(self, keyword, page, first_search):
  480. date_str = time.strftime("%Y-%m-%d %H:%M:%S")
  481. return {
  482. "platform": "pc",
  483. "version": "6.0.0",
  484. "ua": "Chrome146",
  485. 'ex': '{} drugInfo {} {}'.format(self.start_date, date_str, date_str),
  486. "trafficType": 1,
  487. "ex1": "",
  488. "o": "",
  489. "lastClick": -1,
  490. "page": page,
  491. "pagesize": "60",
  492. "classify_id": "",
  493. "searchkey": keyword,
  494. "onlyTcm": 0,
  495. "operationtype": 1,
  496. "qualifiedLoanee": 0,
  497. "drugId": -1,
  498. "tagId": "",
  499. "showRecentlyPurchasedFlag": True,
  500. "onlySimpleLoan": 0,
  501. "sn": "",
  502. "buttons": [],
  503. "buttonList": [],
  504. "synonymId": 0,
  505. "activityTypes": [],
  506. "provider_filter": "",
  507. "factoryNames": "",
  508. "tcmGradeNames": [],
  509. "tcmExeStandardIds": [],
  510. "specs": "",
  511. "deliverFloor": 0,
  512. "purchaseLimitFloor": 0,
  513. "nextRequestKey": "",
  514. "adConfigId": 0,
  515. "stateValue": self._state_value,
  516. "firstSearch": first_search,
  517. "token": self.token,
  518. }
  519. @staticmethod
  520. def _extract_state_value(json_data, data_block):
  521. for src in (json_data, data_block):
  522. if not isinstance(src, dict):
  523. continue
  524. val = src.get("stateValue") or src.get("state_value")
  525. if val:
  526. return str(val)
  527. return None
  528. def _dismiss_popup_before_screenshot(self):
  529. """截图前关闭或隐藏营销弹窗,避免遮挡。"""
  530. self.driver.run_js("""
  531. // 暴力清除所有高 z-index 遮罩和弹窗
  532. document.querySelectorAll('*').forEach(el => {
  533. const s = getComputedStyle(el);
  534. const z = parseInt(s.zIndex) || 0;
  535. if (z > 100 && (s.position === 'fixed' || s.position === 'absolute') &&
  536. (el.clientWidth > 100 || el.clientHeight > 100)) {
  537. el.remove();
  538. }
  539. });
  540. """)
  541. close_locs = [
  542. "xpath=//div[contains(@class,'dialog')]//i[contains(@class,'close')]",
  543. "xpath=//div[contains(@class,'popup')]//i[contains(@class,'close')]",
  544. "xpath=//div[contains(@class,'modal')]//i[contains(@class,'close')]",
  545. "xpath=//button[contains(@class,'close')]",
  546. "xpath=//span[text()='×']",
  547. "xpath=//*[contains(text(),'智能采购')]/ancestor::div[1]//*[contains(@class,'close')]",
  548. "xpath=//*[contains(text(),'智能采购')]/ancestor::div[3]//*[contains(@class,'close')]",
  549. "xpath=//*[contains(text(),'智能采购')]/ancestor::div[3]//button[last()]",
  550. ]
  551. for loc in close_locs:
  552. try:
  553. btn = self.driver.ele(loc, timeout=0.5)
  554. if btn:
  555. btn.click()
  556. time.sleep(0.2)
  557. except Exception:
  558. pass
  559. try:
  560. # 兜底:隐藏常见高层弹窗和遮罩
  561. self.driver.run_js(
  562. """
  563. const sels = [
  564. '[class*="modal"]',
  565. '[class*="popup"]',
  566. '[class*="dialog"]',
  567. '[class*="mask"]',
  568. '[class*="overlay"]'
  569. ];
  570. for (const s of sels) {
  571. document.querySelectorAll(s).forEach(el => {
  572. const style = getComputedStyle(el);
  573. const z = parseInt(style.zIndex || '0', 10);
  574. if (z >= 999 && style.display !== 'none') {
  575. el.style.display = 'none';
  576. }
  577. });
  578. }
  579. document.body.style.overflow = 'auto';
  580. """
  581. )
  582. time.sleep(0.2)
  583. except Exception:
  584. pass
  585. def to_product(self, item):
  586. now = time.strftime("%Y-%m-%d %H:%M:%S")
  587. item_id = item.get("wholesaleid", "")
  588. provider_id = item.get("providerId", "")
  589. city_str = item.get("warehouseCity", "")
  590. city_id = province_id = city = province = ""
  591. price = item.get("disPrice", "")
  592. if not price:
  593. price = item.get("minprice", "")
  594. if not price:
  595. price = item.get("price", "")
  596. shop_name = item.get("provider_name", "")
  597. if not shop_name:
  598. shop_name = item.get("abbreviation", "")
  599. product = {
  600. "platform": self.platform,
  601. "item_id": item_id,
  602. "enterprise_id": self.company_id,
  603. "product_name": item.get("drugname", ""),
  604. "spec": item.get("specification", ""),
  605. "one_price": '',
  606. "detail_url": f"https://dian.ysbang.cn/#/drugInfo?wholesaleid={item_id}&trafficType=1",
  607. "shop_name": shop_name,
  608. "anonymous_store_name": "",
  609. "shop_url": f"https://dian.ysbang.cn/#/supplierstore?providerId={provider_id}&trafficType=4",
  610. "city_name": city,
  611. "city_id": city_id,
  612. "province_name": province,
  613. "province_id": province_id,
  614. "area_info": "",
  615. "factory_name": item.get("manufacturer", ""),
  616. "scrape_date": time.strftime("%Y-%m-%d"),
  617. "price": price,
  618. "sales": "",
  619. "stock_count": item.get("stockAvailable", ""),
  620. "snapshot_url": "",
  621. "approval_num": "",
  622. "produced_time": item.get("prodDate", ""),
  623. "deadline": item.get("valid_date", ""),
  624. "update_time": now,
  625. "insert_time": now,
  626. "number": 1,
  627. "product_brand": self.brand or "",
  628. "collect_task_id": self.collect_task_id,
  629. "search_name": self.product,
  630. "company_name": "",
  631. "collect_config_info": json.dumps(
  632. {"sampling_cycle": self.sampling_cycle, "sampling_start_time": self.sampling_start_time,
  633. "sampling_end_time": self.sampling_end_time}),
  634. "account_id": self.account_id,
  635. "collect_region_id": self.collect_region_id,
  636. "collect_round": self.collect_round,
  637. "is_sold_out": 0
  638. }
  639. return product
  640. def parse_detail(self, product):
  641. appvolnum_ele = self.driver.ele(
  642. 'xpath://div[@class="drug-info"]//span[contains(text(),"批准文号")]/following-sibling::span[1]')
  643. appvolnum_value = appvolnum_ele.text if appvolnum_ele else ""
  644. price = ""
  645. discount_ele = self.driver.ele(
  646. 'xpath://div[@class="sale-info-wrap"]//div[@class="tooltip-content"]',
  647. timeout=2,
  648. )
  649. discount_value = discount_ele.text if discount_ele else ""
  650. if not price and discount_value:
  651. price_re = re.search(r"¥([0-9.]+)", discount_value)
  652. if price_re:
  653. price = price_re.group(1).strip()
  654. current_ele = self.driver.ele(
  655. 'xpath://div[@class="sale-info-wrap"]//span[contains(@class,"current-price")]',
  656. timeout=3,
  657. )
  658. if current_ele and not price:
  659. price = (current_ele.text or "").replace("¥", "").strip()
  660. list_price = product.get("price", "")
  661. if price:
  662. product["price"] = price
  663. if appvolnum_value:
  664. product["approval_num"] = appvolnum_value
  665. logger.info(
  666. "详情解析 wholesaleid=%s list_price=%s dom_price=%s url=%s",
  667. product.get("item_id"),
  668. list_price,
  669. product.get("price"),
  670. self._current_url(),
  671. )
  672. return product
  673. def search(self):
  674. self.driver.get("https://dian.ysbang.cn/#/home", timeout=15)
  675. self.driver.wait.doc_loaded(timeout=10)
  676. time.sleep(2)
  677. if not self._is_logged_in():
  678. if not self.login():
  679. return False
  680. time.sleep(3)
  681. cookies_list = self.driver.cookies()
  682. cookies_dict = {c['name']: c['value'] for c in cookies_list}
  683. self.save_cookies(cookies_list)
  684. self.token = cookies_dict.get("Token") or cookies_dict.get("token")
  685. keyword = self.product
  686. if self.brand:
  687. keyword = (self.brand + " " + self.product).strip()
  688. if self.product_desc:
  689. keyword = (keyword + " " + self.product_desc).strip()
  690. self._state_value = ""
  691. # 支持续传:从 current_page(>0)或 start_page 开始
  692. page_start = int(self.task_dict.get("current_page", 0) or 0)
  693. if page_start <= 0:
  694. page_start = self.start_page
  695. self.page = page_start # 兜底:避免0页时回告 total_pages=0
  696. for page in range(page_start, 100):
  697. if not self._solve_slider_captcha():
  698. logger.error("验证码处理失败,停止任务")
  699. self._report_exception(3, "验证码处理失败,停止当前任务")
  700. return False
  701. first_search = page == 1
  702. logger.info("药师帮爬取第%s页 firstSearch=%s stateValue=%s", page, first_search,
  703. self._state_value or "(空)")
  704. pair = self.gen_pair()
  705. payload = self.build_base_payload(keyword, page=page, first_search=first_search)
  706. payload["ex1"] = pair["ex1"]
  707. payload["o"] = pair["o"]
  708. response = None
  709. for attempt in range(3):
  710. try:
  711. response = requests.post(
  712. "https://dian.ysbang.cn/wholesale-drug/sales/getWholesaleList/v4270", headers=headers,
  713. json=payload, timeout=30
  714. )
  715. if response.status_code == 200:
  716. break
  717. except Exception as e:
  718. logger.error("第%s页请求失败 (%s/3): %s", page, attempt + 1, e)
  719. response = None
  720. time.sleep(10)
  721. if not response or response.status_code != 200:
  722. logger.error("第%s页请求失败,停止爬取", page)
  723. self._report_exception(5, f"第{page}页请求失败(status={response.status_code if response else 'None'})")
  724. return False
  725. try:
  726. data_json = response.json()
  727. except json.JSONDecodeError:
  728. logger.exception("第%s页响应不是合法 JSON", page)
  729. self._report_exception(5, f"第{page}页响应非合法JSON")
  730. return False
  731. data_block = data_json.get("data") or {}
  732. if str(data_json.get("message", "")) == "该操作需要登录":
  733. logger.warning("第%s页需要登录,请检查浏览器登录态", page)
  734. self._report_exception(1, "账号登录态失效")
  735. return False
  736. encrypted_o = data_block.get("o")
  737. if not encrypted_o:
  738. logger.warning("第%s页返回无加密 data.o: %s", page, data_json)
  739. break
  740. try:
  741. json_data = decrypt_ysb_payload(encrypted_o)
  742. except Exception as e:
  743. logger.exception("第%s页解密失败: %s", page, e)
  744. self._report_exception(6, f"第{page}页数据解密失败")
  745. continue
  746. state_val = self._extract_state_value(json_data, data_block)
  747. logger.info("第%s页 解密后 keys: %s, stateValue=%s, has_wholesales=%s", page,
  748. list(json_data.keys())[:20], state_val, bool(json_data.get("wholesales")))
  749. if state_val:
  750. self._state_value = state_val
  751. wholesales = json_data.get("wholesales", [])
  752. if not wholesales:
  753. logger.info(f"第{page}页无数据,停止")
  754. break
  755. for item in wholesales:
  756. item_id = item.get("wholesaleid", "")
  757. if not item_id:
  758. continue
  759. detail_url = (
  760. f"https://dian.ysbang.cn/#/drugInfo?wholesaleid={item_id}&trafficType=1"
  761. )
  762. product = self.to_product(item)
  763. title = product.get("product_name", "")
  764. if self.brand not in title:
  765. self.is_product_count += 1
  766. continue
  767. if self.product not in title:
  768. self.is_product_count += 1
  769. continue
  770. if self.product in title and self.brand in title:
  771. self.is_product_count = 0
  772. if self.is_product_count >= 20:
  773. return False
  774. if not self._goto_detail_page(item_id, detail_url):
  775. logger.warning(
  776. "详情页跳转失败,跳过 item_id=%s url=%s",
  777. item_id, detail_url,
  778. )
  779. continue
  780. product = self.parse_detail(product)
  781. upload_key = hashlib.md5(detail_url.encode("utf-8")).hexdigest()
  782. product["snapshot_url"] = self._take_snapshot(upload_key)
  783. try:
  784. self.pipeline.storge_data(product)
  785. logger.info("%s", json.dumps(product, ensure_ascii=False, default=str))
  786. except Exception as e:
  787. logger.exception("写入数据库失败: %s", e)
  788. # 逐页回告进度
  789. self.page = page
  790. if self._report_page_progress(page, self.pipeline.crawl_count) == True:
  791. break
  792. def run(self):
  793. account_dict = self.get_account()
  794. if not account_dict:
  795. logger.error("ysb快照模式无账号可用")
  796. self._report_exception(1, "无可用账号")
  797. return 0, False
  798. try:
  799. self.init_browser()
  800. self.search()
  801. except Exception as e:
  802. logger.exception("运行异常: %s", e)
  803. self.success = False
  804. self._report_exception(5, f"未知异常: {e}")
  805. finally:
  806. self._quit_browser()
  807. return self.pipeline.crawl_count, self.success, self.page