ysbang_crawl.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. import base64
  2. import hashlib
  3. import json
  4. import random
  5. import re
  6. import secrets
  7. import string
  8. import time
  9. import token
  10. import zlib
  11. from datetime import datetime, timedelta
  12. import requests
  13. from Crypto.Cipher import AES
  14. from openpyxl.worksheet import page
  15. from commons.Logger import get_spider_logger
  16. from pipelines.drug_pipelines import DrugPipeline
  17. from area_info.city_name_to_id import get_city
  18. from commons.conn_mysql import MySQLPoolOn2
  19. from spiders.yaoshibang.login_yaoshibang import YaoShiBangLogin
  20. logger = get_spider_logger("yaoshibang")
  21. class YsbSpider:
  22. def __init__(self, drug_dict=None):
  23. self.url = "https://dian.ysbang.cn/wholesale-drug/sales/getWholesaleList/v4270"
  24. self.headers = self.build_headers()
  25. self.start_date = (datetime.now() - timedelta(minutes=500)).strftime("%Y-%m-%d %H:%M")
  26. self.platform = 5
  27. self.page = 0
  28. self.approval_num = ""
  29. self.task_dict = drug_dict or {}
  30. self.collect_task_id = None
  31. self.token = None
  32. self.account_name = "x"
  33. self.pipeline = DrugPipeline("yaoshibang")
  34. if self.task_dict:
  35. self.get_product_data()
  36. self.is_success = True
  37. self.db_online = MySQLPoolOn2()
  38. self.is_product_count = 0
  39. def get_token(self, _retry_login=False):
  40. sql_account = f""" select `name`,`cookie_str` from `accounts_platform` where `platform`=5 and `status`=1 and `equipment_id`=3 order by `cookie_timestamp` asc limit 1 """
  41. account_list = self.db_online.select_data(sql_account)
  42. if not account_list:
  43. logger.error("无可用爬取账号")
  44. return None
  45. account_dict = account_list[0]
  46. self.account_name = account_dict["name"]
  47. cookie_str = account_dict.get("cookie_str") or ""
  48. if not cookie_str:
  49. if _retry_login:
  50. logger.error("账号 %s 登录后 cookie 仍为空", self.account_name)
  51. return None
  52. logger.warning("账号 %s cookie_str 为空,尝试登录", self.account_name)
  53. YaoShiBangLogin().run()
  54. time.sleep(5)
  55. return self.get_token(_retry_login=True)
  56. try:
  57. cookie_dict = json.loads(cookie_str)
  58. except json.JSONDecodeError:
  59. logger.exception("账号 %s cookie_str 不是合法 JSON", self.account_name)
  60. return None
  61. token = cookie_dict.get("Token") or cookie_dict.get("token")
  62. if not token:
  63. logger.error("账号 %s cookie 中无 Token 字段: %s", self.account_name, list(cookie_dict.keys()))
  64. return None
  65. self.token = token
  66. logger.info("已刷新 token,账号=%s", self.account_name)
  67. return self.token
  68. def get_product_data(self):
  69. self.task_id = self.task_dict["id"]
  70. self.company_id = self.task_dict["company_id"]
  71. self.product = self.task_dict["product_name"]
  72. self.product_desc = self.task_dict.get("product_specs", "")
  73. self.brand = self.task_dict.get("product_brand", "")
  74. self.product_keyword = self.task_dict.get("product_keyword", "")
  75. self.collect_task_id = self.task_dict.get("collect_task_id", "")
  76. self.sampling_cycle = self.task_dict.get("sampling_cycle", "")
  77. self.sampling_start_time = self.task_dict.get("sampling_start_time", "")
  78. self.sampling_end_time = self.task_dict.get("sampling_end_time", "")
  79. self.collect_equipment_id = self.task_dict.get("collect_equipment_id", "")
  80. self.account_id = self.task_dict.get("collect_equipment_account_id", "")
  81. self.collect_region_id = self.task_dict.get("collect_region_id", "")
  82. self.collect_round = self.task_dict.get("collect_round", 1)
  83. def _report_exception(self, exception_type, remark=""):
  84. """异常回告,上报 exception_type (1-6)"""
  85. scheduler = self.task_dict.get("scheduler")
  86. if scheduler:
  87. data = {
  88. "task_id": self.task_id,
  89. "platform": self.task_dict.get("_platform", str(self.platform)),
  90. "username": self.task_dict.get("_device_id", ""),
  91. "current_page": self.page,
  92. "is_finished": 0,
  93. "exception_type": exception_type,
  94. "remark": remark,
  95. }
  96. if self.page:
  97. data["total_pages"] = self.page
  98. if self.pipeline.crawl_count:
  99. data["crawled_count"] = self.pipeline.crawl_count
  100. scheduler.post_report(data)
  101. def _report_page_progress(self, page, crawled_count, is_finished=0, total_pages=0):
  102. """逐页回告进度,返回 True=code=error 需停止"""
  103. scheduler = self.task_dict.get("scheduler")
  104. if not scheduler:
  105. return False
  106. data = {
  107. "task_id": self.task_id,
  108. "platform": self.task_dict.get("_platform", str(self.platform)),
  109. "username": self.task_dict.get("_device_id", ""),
  110. "current_page": page,
  111. "crawled_count": crawled_count,
  112. "is_finished": is_finished,
  113. }
  114. if total_pages:
  115. data["total_pages"] = total_pages
  116. scheduler.post_report(data)
  117. return scheduler.end
  118. def pkcs7_unpad(self, data):
  119. if not data:
  120. raise ValueError("Empty data for PKCS7 unpad")
  121. pad_len = data[-1]
  122. if pad_len < 1 or pad_len > 16:
  123. raise ValueError("Invalid PKCS7 padding length")
  124. if data[-pad_len:] != bytes([pad_len]) * pad_len:
  125. raise ValueError("Invalid PKCS7 padding bytes")
  126. return data[:-pad_len]
  127. def derive_key(self):
  128. base = "BhCLxFfFhd12K4qRGPfy"
  129. md5_hex = hashlib.md5(base.encode("utf-8")).hexdigest()
  130. return md5_hex[:16].upper().encode("utf-8")
  131. def decrypt_payload(self, cipher_text_b64):
  132. key = self.derive_key()
  133. cipher_bytes = base64.b64decode(cipher_text_b64)
  134. cipher = AES.new(key, AES.MODE_ECB)
  135. decrypted = cipher.decrypt(cipher_bytes)
  136. unpadded = self.pkcs7_unpad(decrypted)
  137. json_bytes = zlib.decompress(unpadded, zlib.MAX_WBITS | 16)
  138. return json.loads(json_bytes.decode("utf-8"))
  139. def gen_pair(self, ex1_len=9, o_raw_len=16):
  140. alphabet = string.ascii_lowercase + string.digits
  141. ex1 = "".join(secrets.choice(alphabet) for _ in range(ex1_len))
  142. o = base64.b64encode(secrets.token_bytes(o_raw_len)).decode("ascii")
  143. return {"ex1": ex1, "o": o}
  144. def build_headers(self):
  145. return {
  146. "Accept": "*/*",
  147. "Accept-Language": "zh-CN,zh;q=0.9",
  148. "Connection": "keep-alive",
  149. "Content-Type": "application/json",
  150. "Origin": "https://dian.ysbang.cn",
  151. "Referer": "https://dian.ysbang.cn/",
  152. "Sec-Fetch-Dest": "empty",
  153. "Sec-Fetch-Mode": "cors",
  154. "Sec-Fetch-Site": "same-origin",
  155. "User-Agent": (
  156. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
  157. "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
  158. ),
  159. "sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
  160. "sec-ch-ua-mobile": "?0",
  161. "sec-ch-ua-platform": '"Windows"',
  162. }
  163. def build_base_payload(self):
  164. keyword = self.product
  165. if self.brand:
  166. keyword = self.brand + " " + self.product
  167. if self.product_desc:
  168. keyword = keyword + self.product_desc
  169. date_str = time.strftime("%Y-%m-%d %H:%M")
  170. json_data = {
  171. 'platform': 'pc',
  172. 'version': '6.1.10',
  173. 'ua': 'Chrome148',
  174. 'ex': f'{date_str} https://dian.ysbang.cn os=Windows 10 indexContent 05-25 11:20:53 05-27 17:27:01',
  175. 'trafficType': 1,
  176. 'ex1': '',
  177. 'o': '',
  178. 'lastClick': -1,
  179. 'page': page,
  180. 'pagesize': '60',
  181. 'classify_id': '',
  182. 'searchkey': keyword,
  183. 'onlyTcm': 0,
  184. 'operationtype': 1,
  185. 'qualifiedLoanee': 0,
  186. 'drugId': -1,
  187. 'tagId': '',
  188. 'showRecentlyPurchasedFlag': True,
  189. 'onlySimpleLoan': 0,
  190. 'sn': '',
  191. 'buttons': [],
  192. 'buttonList': [],
  193. 'synonymId': 0,
  194. 'activityTypes': [],
  195. 'provider_filter': '',
  196. 'factoryNames': '',
  197. 'tcmGradeNames': [],
  198. 'tcmExeStandardIds': [],
  199. 'specs': '',
  200. 'deliverFloor': 0,
  201. 'purchaseLimitFloor': 0,
  202. 'nextRequestKey': '',
  203. 'adConfigId': 0,
  204. 'stateValue': '',
  205. 'filterLeyoProvider': False,
  206. 'firstSearch': False,
  207. 'token': self.token,
  208. }
  209. return json_data
  210. def get_price(self, price_token):
  211. pattern = re.compile(r'(?<!\d)(\d+\.\d{2})(?!\d)')
  212. decoded = base64.b64decode(price_token)
  213. text_part = decoded.decode('utf-8', errors='ignore')
  214. numbers_from_text = pattern.findall(text_part)
  215. unique_prices = list(set(round(float(a), 2) for a in numbers_from_text))
  216. if not unique_prices:
  217. return ""
  218. last_prices = sorted(unique_prices, reverse=True)
  219. return last_prices[-1]
  220. def to_product(self, item, type_data):
  221. now = time.strftime("%Y-%m-%d %H:%M:%S")
  222. item_id = item.get("wholesaleid", "")
  223. provider_id = item.get("providerId", "")
  224. shop_url = f"https://dian.ysbang.cn/#/supplierstore?providerId={provider_id}&trafficType=4",
  225. city_str = item.get("warehouseCity", "")
  226. city_id = province_id = city = province = ""
  227. price = item.get("disPrice", "")
  228. if not price:
  229. price = item.get("minprice", "")
  230. if not price:
  231. price = item.get("price", "")
  232. if not price:
  233. price_token = item.get("priceToken", "")
  234. if price_token:
  235. price = self.get_price(price_token)
  236. if not price:
  237. city_str, price = self.parse_detail(item_id, type_data)
  238. if city_str:
  239. city_id, province_id, city, province = get_city(city_str)
  240. shop_name = item.get("provider_name", "")
  241. if not shop_name:
  242. shop_name = item.get("abbreviation", "")
  243. brand = item.get("brand","")
  244. # 销量: 优先 order_amount > orderAmountPlus > alreadysales
  245. sales = (
  246. item.get("order_amount")
  247. or item.get("orderAmountPlus")
  248. or item.get("alreadysales")
  249. or ""
  250. )
  251. product = {
  252. "platform": self.platform,
  253. "item_id": item_id,
  254. "enterprise_id": self.company_id,
  255. "product_name": item.get("drugname", ""),
  256. "spec": item.get("specification", ""),
  257. "one_price": '',
  258. "detail_url": f"https://dian.ysbang.cn/#/drugInfo?wholesaleid={item_id}&trafficType=1",
  259. "shop_name": shop_name,
  260. "anonymous_store_name": "",
  261. "shop_url": f"https://dian.ysbang.cn/#/supplierstore?providerId={provider_id}&trafficType=4",
  262. "city_name": city,
  263. "city_id": city_id,
  264. "province_name": province,
  265. "province_id": province_id,
  266. "area_info": "",
  267. "factory_name": item.get("manufacturer", ""),
  268. "scrape_date": time.strftime("%Y-%m-%d"),
  269. "price": price,
  270. "sales": sales,
  271. "stock_count": item.get("stockAvailable", ""),
  272. "snapshot_url": "",
  273. "approval_num": self.approval_num,
  274. "produced_time": item.get("prodDate", ""),
  275. "deadline": item.get("valid_date", ""),
  276. "update_time": now,
  277. "insert_time": now,
  278. "number": 1,
  279. "product_brand": brand,
  280. "collect_task_id": self.collect_task_id,
  281. "search_name": self.product,
  282. "company_name": "",
  283. "collect_config_info": json.dumps(
  284. {"sampling_cycle": self.sampling_cycle, "sampling_start_time": self.sampling_start_time,
  285. "sampling_end_time": self.sampling_end_time}),
  286. "account_id": self.account_id,
  287. "collect_region_id": self.collect_region_id,
  288. "collect_round": self.collect_round,
  289. "is_sold_out": 0
  290. }
  291. return product
  292. def parse_detail(self, product_id, type_data):
  293. date_str = time.strftime("%Y-%m-%d %H:%M:%S")
  294. json_data = {
  295. 'platform': 'pc',
  296. 'version': '6.0.0',
  297. 'ua': 'Chrome146',
  298. 'ex': '{} drugInfo {} {}'.format(self.start_date, date_str, date_str),
  299. 'trafficType': 1,
  300. 'ex1': 'qtrcqlxew',
  301. 'wholesaleid': str(product_id),
  302. 'showRecentlyPurchasedFlag': True,
  303. 'isClinic': 0,
  304. 'scene': [1, ],
  305. 'adConfigId': 0,
  306. 'token': self.token,
  307. }
  308. if type_data in [7]:
  309. json_data["wholesaleId"] = str(product_id)
  310. url = "https://dian.ysbang.cn/wholesale-drug/api/teambuy/getActivityDetail/v4260"
  311. else:
  312. json_data['wholesaleid'] = str(product_id)
  313. url = 'https://dian.ysbang.cn/wholesale-drug/sales/getPreferenceDetail/v5280'
  314. time.sleep(random.uniform(1, 3))
  315. response = requests.post(url, headers=self.headers, json=json_data)
  316. data_json = response.json()
  317. data = data_json.get("data", {})
  318. self.approval_num = data.get("druginfo", {}).get("approval", "")
  319. if not self.approval_num:
  320. team_info = (data.get("teamBuyDetailDrugInfo") or {}).get(
  321. "teamBuyDetailSingleSaleTypeDrugInfo"
  322. ) or {}
  323. self.approval_num = team_info.get("approval", "") or ""
  324. city_data = data.get("delivery_policy") or ""
  325. city_re = re.search(r"\[(\w+)\w+\]", str(city_data))
  326. price_dict = data.get("disPriceInfo", {})
  327. if not price_dict:
  328. price_dict = data.get("teamBuyDetailInfo", {})
  329. price = price_dict.get("disPrice", "")
  330. if not price:
  331. price = price_dict.get("minprice", "")
  332. if not price:
  333. price = data.get("price", "")
  334. if city_re:
  335. city_str = city_re.group(1)
  336. else:
  337. city_str = ""
  338. return city_str, price
  339. def search_data(self):
  340. if not self.task_dict:
  341. return
  342. # 支持续传:从 task_dict 的 current_page(>0)或 start_page 开始
  343. page = int(self.task_dict.get("current_page", 0) or 0)
  344. if page <= 0:
  345. page = int(self.task_dict.get("start_page", 1) or 1)
  346. while page < 100:
  347. logger.info(f"药师帮爬取第{page}页")
  348. pair = self.gen_pair()
  349. payload = self.build_base_payload()
  350. payload["ex1"] = pair["ex1"]
  351. payload["o"] = pair["o"]
  352. payload["page"] = page
  353. response = None
  354. for attempt in range(3):
  355. try:
  356. response = requests.post(
  357. self.url, headers=self.headers, json=payload, timeout=30
  358. )
  359. if response.status_code == 200:
  360. break
  361. except Exception as e:
  362. logger.error("第%s页请求失败 (%s/3): %s", page, attempt + 1, e)
  363. response = None
  364. time.sleep(10)
  365. if not response or response.status_code != 200:
  366. self.is_success = False
  367. logger.error("第%s页请求失败,停止爬取", page)
  368. self._report_exception(5, f"第{page}页请求失败(status={response.status_code if response else 'None'})")
  369. return
  370. try:
  371. data_json = response.json()
  372. except json.JSONDecodeError:
  373. logger.exception("第%s页响应不是合法 JSON", page)
  374. self.is_success = False
  375. self._report_exception(5, f"第{page}页响应非合法JSON")
  376. return
  377. data_block = data_json.get("data") or {}
  378. if data_json.get("message", "") in ["该操作需要登录","需要前端行为验证!"] :
  379. logger.info("登录账号中。。。")
  380. YaoShiBangLogin().run()
  381. time.sleep(10)
  382. if not self.get_token():
  383. logger.error("登录后仍未从库中读到有效 Token,停止重试")
  384. self.is_success = False
  385. self._report_exception(1, "账号登录过期,重登后仍无有效Token")
  386. return
  387. logger.info("token 已刷新,重试第 %s 页", page)
  388. continue
  389. encrypted_o = data_block.get("o")
  390. if not encrypted_o:
  391. logger.warning("第%s页返回无加密 data.o: %s", page, data_json)
  392. break
  393. try:
  394. json_data = self.decrypt_payload(encrypted_o)
  395. except Exception as e:
  396. logger.exception("第%s页解密失败: %s", page, e)
  397. self.is_success = False
  398. self._report_exception(6, f"第{page}页数据解密失败")
  399. return
  400. wholesales = json_data.get("wholesales", [])
  401. if not wholesales:
  402. logger.info(f"第{page}页无数据,停止")
  403. break
  404. # 获取国药准字
  405. for item in wholesales[0:5]:
  406. product_id = item.get("wholesaleid", "")
  407. type_data = item.get("activitytype", 0)
  408. self.parse_detail(product_id, type_data)
  409. if self.approval_num:
  410. break
  411. for item in wholesales:
  412. type_data = item.get("activitytype", 0)
  413. product = self.to_product(item, type_data)
  414. if not product.get("item_id"):
  415. continue
  416. title = product.get("product_name","")
  417. if self.brand not in title:
  418. self.is_product_count +=1
  419. if self.product not in title:
  420. self.is_product_count +=1
  421. continue
  422. if self.product in title and self.brand in title:
  423. self.is_product_count = 0
  424. try:
  425. self.pipeline.storge_data(product)
  426. logger.info("%s", json.dumps(product, ensure_ascii=False))
  427. except Exception as e:
  428. logger.exception("写入数据库失败: %s", e)
  429. if self.is_product_count >= 20:
  430. return
  431. page += 1
  432. self.page = page
  433. # 逐页回告进度
  434. if self._report_page_progress(page, self.pipeline.crawl_count) == True:
  435. break
  436. def run(self):
  437. if not self.get_token():
  438. logger.error("启动失败:无可用 token")
  439. self._report_exception(1, "无可用账号或Token")
  440. return 0, False
  441. try:
  442. self.search_data()
  443. except Exception as e:
  444. self.is_success = False
  445. logger.error(e)
  446. self._report_exception(5, f"未知异常: {e}")
  447. logger.info(f"爬取总数{self.pipeline.crawl_count}爬取页数{self.page}")
  448. return self.pipeline.crawl_count, self.is_success ,self.page