yaofangwang_crawl.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. import json
  2. import random
  3. import re
  4. import time
  5. import socket
  6. from urllib.parse import quote
  7. from decimal import Decimal, InvalidOperation
  8. from lxml import etree
  9. from commons.Logger import get_spider_logger
  10. from DrissionPage import ChromiumPage, ChromiumOptions
  11. from pipelines.drug_pipelines import DrugPipeline
  12. from area_info.city_name_to_id import get_city
  13. from oss_upload.oss_upload import AliyunOSSUploader
  14. from commons.config import (
  15. CHROME_PATH,
  16. UA_YAOFANGWANG,
  17. YAOFANGWANG_DETAIL_MAX_PAGES,
  18. PLATFORM_ID_YAOFANGWANG,
  19. )
  20. logger = get_spider_logger("yaofangwang")
  21. MEDICINE_DETAIL_MAX_PAGES = YAOFANGWANG_DETAIL_MAX_PAGES
  22. WAIT_BETWEEN_PAGES = (2, 4)
  23. chrome_path = CHROME_PATH
  24. class YaofangwangCrawl:
  25. def __init__(self, drug_dict=None,scheduler=None):
  26. self.driver = None
  27. self.ip = ""
  28. self.base_url = "https://www.yaofangwang.com"
  29. self.ua = UA_YAOFANGWANG
  30. self.scheduler =scheduler
  31. self.platform = "11"
  32. self.task_dict = drug_dict or {}
  33. self.collect_task_id = None
  34. self.pipeline = DrugPipeline("yaofangwang")
  35. if self.task_dict:
  36. self.get_product_data()
  37. self.ossuploader = AliyunOSSUploader()
  38. self.is_success = True
  39. self.account_name = ""
  40. def get_product_data(self):
  41. self.task_id = self.task_dict["id"]
  42. self.company_id = self.task_dict["company_id"]
  43. self.product = self.task_dict["product_name"]
  44. self.product_desc = self.task_dict.get("product_specs", "")
  45. self.brand = self.task_dict.get("product_brand", "")
  46. self.product_keyword = self.task_dict.get("product_keyword", "")
  47. self.collect_task_id = self.task_dict.get("collect_task_id", "")
  48. self.sampling_cycle = self.task_dict.get("sampling_cycle", "")
  49. self.sampling_start_time = self.task_dict.get("sampling_start_time", "")
  50. self.sampling_end_time = self.task_dict.get("sampling_end_time", "")
  51. self.collect_equipment_id = self.task_dict.get("collect_equipment_id", "")
  52. self.account_id = self.task_dict.get("collect_equipment_account_id", "")
  53. self.collect_region_id = self.task_dict.get("collect_region_id", "")
  54. self.collect_round = self.task_dict.get("collect_round", 1)
  55. self.report_data = {'task_id':self.task_id,'platform':self.platform,'username':self.scheduler.username,'total_pages':1}
  56. def _error_report(self,data):
  57. self.report_data.update(data)
  58. self.scheduler.stop()
  59. self.scheduler.post_report(self.report_data)
  60. def _success_report(self,data):
  61. self.report_data.update(data)
  62. self.scheduler.post_report(self.report_data)
  63. @staticmethod
  64. def _x1(node, xp):
  65. vals = node.xpath(xp)
  66. return vals[0] if vals else ""
  67. @staticmethod
  68. def replace_str(text):
  69. if text:
  70. return text.replace("\r\n", "").strip()
  71. return ""
  72. @staticmethod
  73. def normalize_price(price):
  74. price = YaofangwangCrawl.replace_str(price)
  75. price = re.sub(r"[^0-9.]", "", price)
  76. if price.count(".") > 1:
  77. head, tail = price.split(".", 1)
  78. tail = tail.replace(".", "")
  79. price = f"{head}.{tail}"
  80. return price
  81. @staticmethod
  82. def _camp_dict_str_values(camp_dict):
  83. """字形表里的数字统一为字符串,避免 0 在 if v 中被当成假值。"""
  84. if not camp_dict:
  85. return camp_dict
  86. return {k: str(v) if isinstance(v, int) else v for k, v in camp_dict.items()}
  87. def get_font(self, font_url):
  88. camp_dict = {
  89. "CC5E": "0",
  90. "3E73": "1",
  91. "B561": "2",
  92. "0F88": "3",
  93. "351D": "4",
  94. "0ECC": "5",
  95. "E171": "6",
  96. "0FFF": "7",
  97. "2FCF": "8",
  98. "2992": "9",
  99. "1C09": "g",
  100. "9887": "m",
  101. "29BE": "x",
  102. "1ECC": "5",
  103. "D6C2": "0",
  104. "31ED": "1",
  105. "9F43": "2",
  106. "398D": "3",
  107. "9220": "4",
  108. "0ED3": "5",
  109. "5B02": "6",
  110. "69E5": "7",
  111. "B899": "8",
  112. "D0AC": "9",
  113. "4A84": "g",
  114. "72A7": "m",
  115. "8C8C": "x",
  116. "BBB9": "0",
  117. "A3CF": "1",
  118. "E7AB": "2",
  119. "B053": "3",
  120. "0ADD": "4",
  121. "9322": "5",
  122. "A719": "6",
  123. "5C70": "7",
  124. "24CC": "8",
  125. "9B54": "9",
  126. "7F78": "Z",
  127. "4203": "H",
  128. "9F3A": "J",
  129. }
  130. return self._camp_dict_str_values(camp_dict)
  131. @staticmethod
  132. def parse_font(camp_dict, raw_str):
  133. raw_str = raw_str or ""
  134. result = []
  135. for ch in raw_str:
  136. cp = ord(ch)
  137. glyph_name = f"{cp:04X}"
  138. if glyph_name in camp_dict and camp_dict[glyph_name]:
  139. result.append(camp_dict[glyph_name])
  140. elif ch in ".:-~ ":
  141. result.append(ch)
  142. else:
  143. result.append(ch)
  144. return "".join(result)
  145. @staticmethod
  146. def _get_free_port():
  147. """获取一个当前可用的本地端口,供 Chrome 调试使用。"""
  148. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  149. s.bind(("127.0.0.1", 0))
  150. return s.getsockname()[1]
  151. def init_drissionpage(self):
  152. co = ChromiumOptions().set_browser_path(chrome_path)
  153. # 获取独立端口
  154. debug_port = self._get_free_port()
  155. # # 设置用户目录(每个账号独立)
  156. # co.set_user_data_path(f"./{self.account_name}")
  157. # 设置端口(重要:两个都要设置)
  158. co.set_local_port(debug_port) # DrissionPage 内部端口
  159. co.set_argument(f"--remote-debugging-port={debug_port}") # Chrome 调试端口
  160. co.set_argument("--remote-debugging-address=127.0.0.1")
  161. # 基础参数
  162. co.set_argument("--disable-dev-shm-usage")
  163. co.set_argument("--no-first-run") # 避免首次运行弹窗
  164. co.set_argument("--no-default-browser-check") # 避免默认浏览器检查
  165. co.set_user_agent(
  166. 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36')
  167. # co.headless() # 注释掉无头模式,以便能看到浏览器界面
  168. co.set_argument('--no-sandbox')
  169. if self.ip:
  170. proxy = self.ip.strip()
  171. if not proxy.startswith(("http://", "https://")):
  172. proxy = f"http://{proxy}"
  173. co.set_argument(f"--proxy-server={proxy}")
  174. self.driver = ChromiumPage(co)
  175. def parse_html(self, html):
  176. """解析详情页 HTML,返回 (data_items, shop_items);无列表节点时返回 ([], [])。"""
  177. tree = etree.HTML(html)
  178. if tree is None:
  179. return [], []
  180. font_match = re.search(r"(/fonts/\w+\.ttf)", html)
  181. camp_dict = {}
  182. if font_match:
  183. font_url = self.base_url + font_match.group(1)
  184. camp_dict = self.get_font(font_url)
  185. approval_number = ""
  186. manufacturer = ""
  187. for dt in tree.xpath("//div[@id='wrap']//dl[@class='clearfix']//dt"):
  188. dt_text = self.replace_str(self._x1(dt, "./text()"))
  189. dd_text = self.replace_str(self._x1(dt, "./following-sibling::dd[1]//text()"))
  190. if not dt_text:
  191. continue
  192. if "批准文号" in dt_text:
  193. approval_number = self.replace_str(
  194. self._x1(
  195. dt,
  196. './following-sibling::dd[1]//div[contains(@class,"ybfont")]/text()',
  197. )
  198. )
  199. approval_number = self.parse_font(camp_dict, approval_number)
  200. if "生产企业" in dt_text:
  201. manufacturer = dd_text
  202. li_list = tree.xpath("//div[@id='slist']//ul[@class='slist']//li")
  203. for li in li_list:
  204. title = self.brand + self.replace_str(self._x1(li, ".//div[@class='info']//h3/a/text()"))
  205. if self.product not in title:
  206. return
  207. detail_url = self._x1(li, './/div[@class="info"]//a/@href')
  208. info_texts = li.xpath('.//div[@class="info"]//p//text()')
  209. info_str = self.replace_str("---------".join(info_texts))
  210. specification = ""
  211. specification_re = re.search(r"规格:(.{12})", info_str)
  212. if specification_re:
  213. specification = specification_re.group(1).strip().strip("-").strip()
  214. inventory = self.replace_str(
  215. self._x1(li, './/div[@class="info"]//label[@class="sreserve"]/text()')
  216. )
  217. price_raw = self._x1(
  218. li, './/div[@class="sale"]//span[contains(@class,"ybfont")]//text()'
  219. )
  220. sale_texts = li.xpath('.//div[@class="sale"]//p//text()')
  221. sale_str = self.replace_str("-".join(sale_texts))
  222. expiry_date = ""
  223. expiry_date_re = re.search(r" 剩余效期:(\d+) 天", sale_str)
  224. if expiry_date_re:
  225. expiry_date = expiry_date_re.group(1) + "天"
  226. shop = self.replace_str(
  227. self._x1(
  228. li,
  229. './/div[@class="shop"]//a[contains(@class,"stitle sc_store")]/text()',
  230. )
  231. )
  232. shop_url = self._x1(
  233. li,
  234. './/div[@class="shop"]//a[contains(@class,"stitle sc_store")]/@href',
  235. )
  236. shop_str = self.replace_str(self._x1(li, './/div[@class="shop"]//p//text()'))
  237. shop_url = "https:" + shop_url
  238. price = self.normalize_price(self.parse_font(camp_dict, price_raw))
  239. m_item = re.search(r"/(\d+)\.html", detail_url or "")
  240. m_shop = re.search(r"yaodian/(\d+)/", shop_url or "")
  241. if not m_item or not m_shop:
  242. continue
  243. item_id = m_item.group(1)
  244. detail_url = f"{self.base_url}{detail_url}"
  245. shop_id = m_shop.group(1)
  246. try:
  247. price = Decimal(str(price)).quantize(Decimal("0.00"))
  248. except (InvalidOperation, ValueError):
  249. price = Decimal("0.00")
  250. city_id = province_id = city = province = ""
  251. if shop_str:
  252. city_id, province_id, city, province = get_city(shop_str)
  253. snapshot_url = ""
  254. try:
  255. snapshot_url = self.get_page_detail(detail_url, item_id) or ""
  256. except Exception as e:
  257. logger.exception("详情页截图或上传失败 item_id=%s: %s", item_id, e)
  258. now = time.strftime("%Y-%m-%d %H:%M:%S")
  259. product = {
  260. "platform": self.platform,
  261. "item_id": item_id,
  262. "enterprise_id": self.company_id,
  263. "product_name": title,
  264. "spec": specification,
  265. "one_price": '',
  266. "detail_url": detail_url,
  267. "shop_name": shop,
  268. "shop_id": shop,
  269. "company_id": shop,
  270. "anonymous_store_name": "",
  271. "shop_url": shop_url,
  272. "city_name": city,
  273. "city_id": city_id,
  274. "province_name": province,
  275. "province_id": province_id,
  276. "factory_name": manufacturer,
  277. "scrape_date": time.strftime("%Y-%m-%d"),
  278. "price": price,
  279. "sales": "",
  280. "stock_count": inventory,
  281. "snapshot_url": snapshot_url,
  282. "approval_num": approval_number,
  283. "produced_time": "",
  284. "deadline": expiry_date,
  285. "update_time": now,
  286. "insert_time": now,
  287. "number": 1,
  288. "product_brand": self.brand or "",
  289. "collect_task_id": self.collect_task_id,
  290. "task_id": self.task_id,
  291. "search_name": self.product,
  292. "company_name": shop,
  293. "collect_config_info": json.dumps(
  294. {"sampling_cycle": self.sampling_cycle, "sampling_start_time": self.sampling_start_time,
  295. "sampling_end_time": self.sampling_end_time}),
  296. "account_id": self.account_id,
  297. "collect_region_id": self.collect_region_id,
  298. "collect_round": self.collect_round,
  299. "is_sold_out": 0
  300. }
  301. try:
  302. self.pipeline.storge_data(product)
  303. logger.info(json.dumps(product, ensure_ascii=False, default=str))
  304. except Exception as e:
  305. logger.exception("写入数据库失败: %s", e)
  306. return len(li_list)
  307. def get_page_detail(self, detail_url, item_id):
  308. """打开详情页、截取 maininfo2 区域并上传 OSS,返回 URL;失败返回空字符串。"""
  309. self.driver.get(detail_url, timeout=10)
  310. time.sleep(2)
  311. try:
  312. ele = self.driver.ele("xpath=//div[@id='wrap']/div[contains(@class,'maininfo2')]")
  313. if ele:
  314. jpg_bytes = ele.get_screenshot(as_bytes="jpg")
  315. img_url = self.ossuploader.upload_from_bytes(jpg_bytes, str(item_id))
  316. time.sleep(random.uniform(0.5, 1))
  317. return img_url
  318. else:
  319. logger.error("详情页未找到截图元素 item_id=%s", item_id)
  320. return ""
  321. except Exception as e:
  322. logger.error("详情页截图异常 item_id=%s: %s", item_id, e)
  323. return ""
  324. def get_list(self, medicine_id):
  325. """按分页拉取同一药品详情下的报价列表,连续空页则停止。"""
  326. for page in range(1, MEDICINE_DETAIL_MAX_PAGES + 1):
  327. url = f"{self.base_url}/medicine/{medicine_id}/p{page}/"
  328. self.driver.get(url, timeout=10)
  329. data_items = self.parse_html(self.driver.html)
  330. if not data_items:
  331. break
  332. time.sleep(random.uniform(*WAIT_BETWEEN_PAGES))
  333. def _search_result_medicine_ids(self, html):
  334. """从搜索结果 HTML 解析 medicine_id,避免遍历 DrissionPage 元素导致 ElementLostError。"""
  335. tree = etree.HTML(html)
  336. if tree is None:
  337. return []
  338. li_list = tree.xpath("//div[@id='wrap']//ul[contains(@class,'goodlist_search')]/li")
  339. id_list = []
  340. for li in li_list:
  341. href_raw = self._x1(li, ".//a/@href")
  342. if not href_raw:
  343. continue
  344. href = "https:" + href_raw if href_raw.startswith("//") else href_raw
  345. spec = self.replace_str(self._x1(li, ".//p[@class='st']/text()"))
  346. factory = self.replace_str(self._x1(li, ".//p[@class='st text-overflow']/text()"))
  347. id_list.append({"spec": spec, "href": href, "factory": factory})
  348. self.driver.get(href, timeout=10)
  349. res_html = etree.HTML(self.driver.html)
  350. lis = res_html.xpath("//div[@id='wrap']//ul[@class='other']//li")
  351. for li_ele in lis:
  352. spec = self.replace_str(self._x1(li_ele, "./a/text()"))
  353. href = self._x1(li_ele, "./a/@href")
  354. if not href:
  355. continue
  356. id_list.append({"spec": spec, "href": href, "factory": factory})
  357. return id_list
  358. def search_data(self):
  359. # 必须用局部变量,不能写 self.search_data = ... ,否则会覆盖掉本方法
  360. keyword = f"{self.brand} {self.product or ''}".strip() if self.brand else (self.product or "")
  361. if not keyword:
  362. logger.warning("关键词为空,跳过搜索")
  363. return
  364. url = f"{self.base_url}/search.html?keyword={quote(keyword)}"
  365. self.driver.get(url, timeout=10)
  366. time.sleep(random.uniform(0.8, 1.5))
  367. drug_list = self._search_result_medicine_ids(self.driver.html)
  368. id_dict = {}
  369. for drug in drug_list:
  370. if self.scheduler.end == True:
  371. return
  372. spec = drug["spec"]
  373. href = drug["href"]
  374. if "x" in self.product_desc:
  375. spec = spec.replace("*", "x")
  376. if "*" in self.product_desc:
  377. spec = spec.replace("x", "*")
  378. print(self.product_desc, spec)
  379. if self.product_desc in spec:
  380. m = re.search(r"/medicine/(\d+)/", href or "")
  381. if not m:
  382. continue
  383. drug_id = m.group(1)
  384. if drug_id in id_dict:
  385. continue
  386. self.get_list(drug_id)
  387. id_dict[drug_id] = 1
  388. def run(self):
  389. if not self.task_dict:
  390. logger.info("未提供任务参数,跳过爬取")
  391. return 0
  392. try:
  393. self.init_drissionpage()
  394. except Exception as e :
  395. print(e)
  396. self._error_report({'is_finished':0,'need_reassign':1,'current_page':1,'exception_type':1})
  397. try:
  398. self.search_data()
  399. self._success_report({'is_finished':1,'need_reassign':0,'current_page':1})
  400. except Exception as e:
  401. print(f"运行异常: {e}")
  402. self.is_success = False
  403. self._error_report({'is_finished':0,'need_reassign':1,'current_page':1,'exception_type':5})
  404. finally:
  405. if self.driver:
  406. self.driver.quit()
  407. logger.info(f"药房网爬取总数:{self.pipeline.crawl_count}条")
  408. return self.pipeline.crawl_count, self.is_success