yaoex_crawl.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import json
  2. import random
  3. import time
  4. import requests
  5. from commons.Logger import get_spider_logger
  6. from commons.scheduler import CrawlerScheduler
  7. import base64
  8. from Crypto.Cipher import AES
  9. from Crypto.Util.Padding import unpad
  10. from pipelines.drug_pipelines import DrugPipeline
  11. from area_info.city_name_to_id import get_city
  12. import hashlib
  13. logger = get_spider_logger("yaoex")
  14. TOKEN = "Sm45MzRmREtiaStVTnJORXEySHhYYzNwUmQ2RUprWXlwelRDem4wV2RZUCtUUU5jMGVCVTRYYjNLVjdNSnFWSjg1YStxWllGQ2RQSExjaEVqU0dOaDFJczl4bTB1V09CZHZzVml2dU0xazd3UDdla3FTUzZBZlZkMHFSVHlaaDhDcFp3SWNDb3JNSDhuNC9vUzI1RVdEaU01YjcxQW5TS21Sdy90ZDRENi9VR2E0SW5wOWF4UE1VZ0poTDhhVkJtP2FwcElkPTEyNTAma2V5SWQ9MTI1MA=="
  15. # 平台常量(API 调度用)
  16. PLATFORM_ID = 6
  17. YYC_DEVICE_ID = "18193030281"
  18. HEARTBEAT_INTERVAL_SECONDS = 60
  19. TASK_API_BASE = "http://pricesys2.kailin.com.cn:8083/api/collect_task"
  20. TASK_API_TOKEN = "zhijiayun_crawler_2026"
  21. TASK_PULL_URL = TASK_API_BASE + "/pull"
  22. TASK_HEARTBEAT_URL = TASK_API_BASE + "/heartbeat"
  23. headers = {
  24. "Accept": "application/json, text/plain, */*",
  25. "Accept-Language": "zh-CN,zh;q=0.9",
  26. "Connection": "keep-alive",
  27. "Content-Type": "application/x-www-form-urlencoded",
  28. "Origin": "https://mall.yaoex.com",
  29. "Referer": "https://mall.yaoex.com/",
  30. "Sec-Fetch-Dest": "empty",
  31. "Sec-Fetch-Mode": "cors",
  32. "Sec-Fetch-Site": "cross-site",
  33. "User-Agent": (
  34. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
  35. "AppleWebKit/537.36 (KHTML, like Gecko) "
  36. "Chrome/146.0.0.0 Safari/537.36"
  37. ),
  38. "X-Request-Agent": "Axios",
  39. "X-Requested-With": "XMLHttpRequest",
  40. "sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
  41. "sec-ch-ua-mobile": "?0",
  42. "sec-ch-ua-platform": '"Windows"',
  43. }
  44. REQUEST_RETRY_COUNT = 3
  45. REQUEST_TIMEOUT_SEC = 20
  46. class YaoexCrawler:
  47. def __init__(self, drug_dict=None):
  48. self.token = TOKEN
  49. self.user_id = "181680"
  50. self.platform = 6
  51. self.task_dict = drug_dict or {}
  52. self.collect_task_id = None
  53. self.account_name = None
  54. self.pipeline = DrugPipeline("yaoex")
  55. self.scheduler = CrawlerScheduler(YYC_DEVICE_ID, str(PLATFORM_ID), heartbeat_interval=HEARTBEAT_INTERVAL_SECONDS)
  56. # 独立回告(不依赖 commons,commons 经常变)
  57. self.TASK_REPORT_URL = "http://pricesys2.kailin.com.cn:8083/api/collect_task/report"
  58. self.TASK_API_TOKEN = "zhijiayun_crawler_2026"
  59. if self.task_dict:
  60. self.get_product_data()
  61. self.is_success = True
  62. self.is_not_product = 0
  63. # ---------- API report helpers ----------
  64. def _send_report(self, params: dict, log_msg: str):
  65. try:
  66. resp = requests.post(
  67. self.TASK_REPORT_URL, json=params, timeout=10,
  68. headers={'X-Crawler-Token': self.TASK_API_TOKEN},
  69. )
  70. if resp.status_code == 200:
  71. try:
  72. data = resp.json()
  73. if data.get("code") == "success":
  74. logger.info("回告成功:%s", log_msg)
  75. return "success"
  76. else:
  77. msg = data.get("msg", "")
  78. if "已达平台限额" in msg or "限额" in msg:
  79. logger.warning("回告接口返回限额错误:code=%s, msg=%s", data.get('code'), msg)
  80. return "limit_reached"
  81. else:
  82. logger.warning("回告接口返回错误:code=%s, msg=%s", data.get('code'), msg)
  83. return "error"
  84. except ValueError:
  85. logger.warning("回告响应非 JSON:%s", resp.text[:200])
  86. return "error"
  87. else:
  88. logger.warning("回告 HTTP %s", resp.status_code)
  89. return "error"
  90. except Exception as e:
  91. logger.error("回告失败:%s,错误:%s", log_msg, str(e))
  92. return "error"
  93. def _report_start(self):
  94. payload = {
  95. "task_id": self.task_id,
  96. "platform": str(self.platform),
  97. "username": YYC_DEVICE_ID,
  98. "current_page": 0,
  99. "total_pages": 0,
  100. "crawled_count": 0,
  101. "is_finished": 0,
  102. }
  103. logger.info("上报开始 task_id=%s", self.task_id)
  104. return self._send_report(payload, "开始上报(is_finished=0)")
  105. def _report_progress(self, current_page: int, total_pages: int = 0):
  106. payload = {
  107. "task_id": self.task_id,
  108. "platform": str(self.platform),
  109. "username": YYC_DEVICE_ID,
  110. "current_page": current_page,
  111. "total_pages": total_pages,
  112. "crawled_count": self.pipeline.crawl_count,
  113. "is_finished": 0,
  114. }
  115. return self._send_report(payload, f"进度上报 page={current_page}")
  116. def _report_end(self, success: bool, real_count: int, page_no: int = 0, total_pages: int = 0):
  117. payload = {
  118. "task_id": self.task_id,
  119. "platform": str(self.platform),
  120. "username": YYC_DEVICE_ID,
  121. "current_page": page_no,
  122. "total_pages": total_pages if total_pages > 0 else page_no,
  123. "crawled_count": real_count,
  124. "is_finished": 1, # 到了 _report_end 必然已经跑完了
  125. }
  126. if not success:
  127. payload["exception_type"] = 5
  128. payload["remark"] = "采集异常或API请求失败"
  129. logger.info("上报结束 task_id=%s payload=%s", self.task_id, payload)
  130. self._send_report(payload, "结束上报(is_finished=1)")
  131. def _post_with_retry(self, url, payload, retries=REQUEST_RETRY_COUNT, timeout=REQUEST_TIMEOUT_SEC):
  132. last_err = None
  133. for attempt in range(1, retries + 1):
  134. try:
  135. resp = requests.post(
  136. url,
  137. headers=headers,
  138. data=payload,
  139. timeout=timeout,
  140. )
  141. resp.raise_for_status()
  142. return resp
  143. except Exception as e:
  144. last_err = e
  145. if attempt < retries:
  146. logger.warning("请求失败,第%s/%s次重试: %s", attempt, retries, e)
  147. time.sleep(min(2 * attempt, 5))
  148. else:
  149. logger.error("请求失败,已达最大重试次数(%s): %s", retries, e)
  150. raise last_err
  151. def get_product_data(self):
  152. self.task_id = self.task_dict["id"]
  153. self.company_id = self.task_dict["company_id"]
  154. self.product = self.task_dict["product_name"]
  155. self.product_desc = self.task_dict.get("product_specs", "")
  156. self.brand = self.task_dict.get("product_brand", "")
  157. self.product_keyword = self.task_dict.get("product_keyword", "")
  158. self.collect_task_id = self.task_dict.get("collect_task_id", "")
  159. self.sampling_cycle = self.task_dict.get("sampling_cycle", "")
  160. self.sampling_start_time = self.task_dict.get("sampling_start_time", "")
  161. self.sampling_end_time = self.task_dict.get("sampling_end_time", "")
  162. self.collect_equipment_id = self.task_dict.get("collect_equipment_id", "")
  163. self.account_id = self.task_dict.get("collect_equipment_account_id", "")
  164. self.collect_region_id = self.task_dict.get("collect_region_id", "")
  165. self.collect_round = self.task_dict.get("collect_round", 1)
  166. @staticmethod
  167. def _timestamp_ms() -> str:
  168. return str(int(time.time() * 1000))
  169. def _list_payload(self, keyword, page):
  170. return {
  171. "traderName": "yaoex_pc",
  172. "trader": "pc",
  173. "closesignature": "yes",
  174. "signature_method": "md5",
  175. "signature": "****",
  176. "timestamp": self._timestamp_ms(),
  177. "token": self.token,
  178. "userToken": self.token,
  179. "userId": self.user_id,
  180. "roleId": "101",
  181. "userType": "下游客户",
  182. "buyerCode": self.user_id,
  183. "nowPage": str(page),
  184. "per": "20",
  185. "keyword": keyword,
  186. "catSearchId": "",
  187. "specs": "",
  188. "factoryIds": "",
  189. "sellerCodes": "",
  190. "sellerFilterMode": "0",
  191. "sortColumn": "default",
  192. "sortMode": "default",
  193. "ver": "1",
  194. "stock_mode": "1",
  195. "showExtendCard": "true",
  196. "needDinnerPrice": "true",
  197. "limitStart": "",
  198. "limitEnd": "",
  199. "deadLineStart": "",
  200. "deadLineEnd": "",
  201. "filterDtos": "",
  202. "showWholePurchase": "true",
  203. }
  204. def _detail_payload(self, spu_code, seller_code):
  205. return {
  206. "traderName": "yaoex_pc",
  207. "trader": "pc",
  208. "closesignature": "yes",
  209. "signature_method": "md5",
  210. "signature": "****",
  211. "timestamp": self._timestamp_ms(),
  212. "token": self.token,
  213. "userToken": self.token,
  214. "spuCode": str(spu_code),
  215. "sellerCode": str(seller_code),
  216. }
  217. def _shop_payload(self, enterprise_id):
  218. return {
  219. 'traderName': 'yaoex_pc',
  220. 'trader': 'pc',
  221. 'closesignature': 'yes',
  222. 'signature_method': 'md5',
  223. 'signature': '****',
  224. 'timestamp': self._timestamp_ms(),
  225. 'token': TOKEN,
  226. 'userToken': TOKEN,
  227. 'enterpriseId': enterprise_id,
  228. }
  229. def fetch_list_page(self, keyword, page):
  230. payload = self._list_payload(keyword, page)
  231. list_url = "https://gateway-b2b.fangkuaiyi.com/home/search/homeSearchList"
  232. resp = self._post_with_retry(list_url, payload)
  233. data = resp.json()
  234. recall_status = data.get("data", {}).get("recallStatus", 0)
  235. data_block = data.get("data", {}) or {}
  236. # 尝试提取总页数(常见字段名依次尝试)
  237. total_pages = 0
  238. for key in ("totalPage", "totalPages", "pageCount", "total", "totalCount", "totalNum", "totalSize"):
  239. val = data_block.get(key)
  240. if val is not None and int(val) > 0:
  241. total_pages = int(val)
  242. break
  243. if total_pages:
  244. logger.info("📊 列表 API 检测到总页数:%s (page=%s)", total_pages, page)
  245. else:
  246. logger.debug("列表 API 未返回总页数字段,data keys: %s", list(data_block.keys())[:20])
  247. if int(recall_status) == 1:
  248. return data_block.get("shopProducts", []) or [], total_pages
  249. else:
  250. return [], 0
  251. def fetch_detail(self, spu_code, seller_code):
  252. payload = self._detail_payload(spu_code, seller_code)
  253. detail_url = "https://gateway-b2b.fangkuaiyi.com/product/detail"
  254. resp = self._post_with_retry(detail_url, payload)
  255. return resp.json().get("data", {}) or {}
  256. def fetch_shop(self, seller_code):
  257. payload = self._shop_payload(seller_code)
  258. detail_url = 'https://gateway-b2b.fangkuaiyi.com/ycapp/shop/enterpriseQualification'
  259. resp = self._post_with_retry(detail_url, payload)
  260. shop_res = resp.json().get("data", {})
  261. base_info = shop_res.get("baseInfo", {})
  262. address = base_info.get("address", "")
  263. company_name = base_info.get("enterpriseName", "")
  264. return address, company_name
  265. def parse_product(self, item):
  266. seller_code = item.get("sellerCode")
  267. spu_code = item.get("spuCode")
  268. name_part = (item.get("productName") or "").strip()
  269. short_part = (item.get("shortName") or "").strip()
  270. product_name = f"{name_part} {short_part}".strip()
  271. shop_url = f"https://mall.yaoex.com/v2/store/#/detail/{seller_code}/home"
  272. # 这里读取数据库,获取城市
  273. company_adress, company_name = self.fetch_shop(seller_code)
  274. detail_json = self.fetch_detail(spu_code, seller_code)
  275. address = detail_json.get("enterpriseIntroduce", {}).get("address", "")
  276. city_id = province_id = city = province = ""
  277. if address:
  278. city_id, province_id, city, province = get_city(address.split("市")[0])
  279. raw_price = item.get("price")
  280. price = self.decrypt_price(raw_price)
  281. hash_text = f"{seller_code}_{spu_code}_{price}"
  282. item_id = hashlib.md5(hash_text.encode('utf-8')).hexdigest()
  283. is_sold_out = 0
  284. is_sold_out_text = item.get("statusDescription", "")
  285. if "商品已售罄" in is_sold_out_text:
  286. is_sold_out= 1
  287. shop_name = item.get("storeName")
  288. if not shop_name:
  289. shop_name = item.get("shopName")
  290. anonymous_store_name = ""
  291. if shop_name == "预约配送中心":
  292. anonymous_store_name = item.get("supplyName", "")
  293. inventory = item.get("currentInventory")
  294. if not inventory:
  295. inventory = item.get("stockCount")
  296. now = time.strftime("%Y-%m-%d %H:%M:%S")
  297. # 字段与 yaofangwang_crawl 中 product 对齐(供 DrugPipeline)
  298. product = {
  299. "platform": self.platform,
  300. "item_id": item_id,
  301. "enterprise_id": self.company_id,
  302. "product_name": product_name,
  303. "spec": item.get("spec"),
  304. "one_price": "",
  305. "detail_url": f"https://mall.yaoex.com/v2/product/#/spuCode/{spu_code}/sellerCode/{seller_code}",
  306. "shop_name": shop_name,
  307. "anonymous_store_name": anonymous_store_name,
  308. "shop_url": shop_url,
  309. "city_name": city,
  310. "city_id": city_id,
  311. "province_name": province,
  312. "province_id": province_id,
  313. "shipment_city_name": "",
  314. "shipment_city_id": "",
  315. "shipment_province_name": "",
  316. "shipment_province_id": "",
  317. "area_info": company_adress if company_adress else address,
  318. "factory_name": item.get("factoryName"),
  319. "scrape_date": time.strftime("%Y-%m-%d"),
  320. "price": price,
  321. "sales": "",
  322. "stock_count": inventory,
  323. "snapshot_url": "",
  324. "approval_num": item.get("approvalNum"),
  325. "produced_time": item.get("productionTime"),
  326. "deadline": item.get("deadLine"),
  327. "update_time": now,
  328. "insert_time": now,
  329. "number": 1,
  330. "product_brand": self.brand or "",
  331. "collect_task_id": self.collect_task_id,
  332. "search_name": self.product,
  333. "company_name": company_name,
  334. "collect_config_info": json.dumps(
  335. {
  336. "sampling_cycle": self.sampling_cycle,
  337. "sampling_start_time": self.sampling_start_time,
  338. "sampling_end_time": self.sampling_end_time,
  339. }
  340. ),
  341. "account_id": self.account_id,
  342. "collect_region_id": self.collect_region_id,
  343. "collect_round": self.collect_round,
  344. "is_sold_out": is_sold_out
  345. }
  346. return product
  347. def decrypt_price(self, ciphertext_b64):
  348. if not ciphertext_b64 or not str(ciphertext_b64).strip():
  349. return ""
  350. _KEY_FIXED = "GDLSAUO1KUMIIBCE"
  351. if not self.user_id:
  352. key = _KEY_FIXED.encode("utf-8")
  353. else:
  354. uid = str(self.user_id)[:6].rjust(6, "0")
  355. key = (_KEY_FIXED[:10] + uid).encode("utf-8")
  356. raw = base64.b64decode(ciphertext_b64.strip())
  357. cipher = AES.new(key, AES.MODE_ECB)
  358. plain = unpad(cipher.decrypt(raw), AES.block_size)
  359. price = plain.decode("utf-8")
  360. return price
  361. def search_data(self):
  362. keyword = self.product
  363. if self.brand:
  364. keyword = self.brand + " " + self.product
  365. if self.product_desc:
  366. keyword = keyword + " " + self.product_desc
  367. for page in range(1, 100):
  368. if self.scheduler.end:
  369. logger.warning("收到停止信号,终止采集")
  370. break
  371. logger.info("正在爬取%s %s,第%s页数据", self.brand, self.product, page)
  372. page_items, api_total_pages = self.fetch_list_page(keyword=keyword, page=page)
  373. if not page_items:
  374. break
  375. for item in page_items:
  376. if not item.get("productId") and item.get("groupBuyProductDto"):
  377. item = item.get("groupBuyProductDto") or {}
  378. name_part = (item.get("productName") or "").strip()
  379. short_part = (item.get("shortName") or "").strip()
  380. product_name = f"{name_part} {short_part}".strip()
  381. if self.product not in product_name:
  382. self.is_not_product += 1
  383. continue
  384. if self.brand not in product_name:
  385. self.is_not_product += 1
  386. continue
  387. self.is_not_product = 0
  388. product = self.parse_product(item)
  389. if not product.get("item_id"):
  390. continue
  391. logger.info(f"爬取到数据{json.dumps(product,ensure_ascii=False)}")
  392. try:
  393. self.pipeline.storge_data(product)
  394. logger.info("%s", json.dumps(product, ensure_ascii=False))
  395. except Exception as e:
  396. logger.exception("写入数据库失败: %s", e)
  397. time.sleep(random.randint(1, 3))
  398. if self.is_not_product > 15:
  399. break
  400. # 每页结束上报进度,检查限额
  401. self._page_no = page
  402. prog_status = self._report_progress(page, total_pages=api_total_pages)
  403. if prog_status == "limit_reached":
  404. logger.warning("⛔ 进度上报检测到限额,通知停止")
  405. self.scheduler.set_flag(True)
  406. time.sleep(random.randint(2, 5))
  407. def run(self):
  408. self._page_no = 0
  409. try:
  410. self.scheduler.start()
  411. self._report_start()
  412. self.search_data()
  413. except Exception as e:
  414. logger.error(e)
  415. self.is_success = False
  416. finally:
  417. self.scheduler.stop()
  418. try:
  419. self._report_end(
  420. success=self.is_success,
  421. real_count=self.pipeline.crawl_count,
  422. page_no=self._page_no,
  423. )
  424. except Exception:
  425. pass
  426. logger.info(f"爬取总数{self.pipeline.crawl_count}")
  427. return self.pipeline.crawl_count, self.is_success